Radio Button Group
A single-select group whose options are cards, built on a real radio group with roving focus, arrow-key selection, and a per-item indicator.
Usage
Anatomy
Import the RadioButtonGroup component and access all parts using dot notation.
import { RadioButtonGroup } from "@blakeui/pro-react";
<RadioButtonGroup>
<RadioButtonGroup.Item>
<RadioButtonGroup.ItemIcon />
<RadioButtonGroup.ItemContent>
<RadioButtonGroup.ItemTitle />
<RadioButtonGroup.ItemDescription />
</RadioButtonGroup.ItemContent>
<RadioButtonGroup.Indicator />
</RadioButtonGroup.Item>
</RadioButtonGroup>;Controlled
Next invoice: $24.00 per seat.
Custom Indicator
Delivery And Payment
Disabled Group
Disabled Item
A disabled card leaves the roving focus order, so the arrow keys step straight past it to the next selectable option.
Grid Layout
Icon Cards
No Indicator
Render Prop Children
A function child receives this item's live selection state, so the card can change its own content on selection without lifting value out of the group.
Subscription Plans
Prices exclude VAT. Change or cancel at any time.
Compare every featureWith Icons
With Ripple
Neither package ships a web ripple, so this composes the third-party m3-ripple — the same one the free Button docs reach for. It sits inside the item as a pointer-events: none overlay, so the press still lands on the radio.
CSS Classes
Base Class
.radio-button-group— The root, which is also the free.radio-group. It declares the three custom properties below and carriescontainer-type: inline-sizeso the list can query it. An element can never match its own container query, which is why the containment sits here rather than on the list.
Layout Classes
.radio-button-group--flex— The default. The list stacks its cards in a column with a--spacing * 3gap..radio-button-group--grid— The list becomesdisplay: gridoverrepeat(var(--radio-button-group-columns), minmax(0, 1fr))with the same gap. Below a26remcontainer it collapses to a single column.
Element Classes
.radio-button-group__label— The group's<label>, with--spacing * 0.5of bottom margin..radio-button-group__description— The supporting line under the label..radio-button-group__list— The box holding the items, and the one the layout modifiers apply to..radio-button-group__item— One card, and also the free.radio. A relatively-positioned flex row aligned toflex-start,--spacing * 4of padding,1px solid --border,border-radius: var(--radio-button-group-item-radius), andbox-shadow: none— bordered-flat, like every card-family surface. Its rules are written as.radio-button-group .radio-button-group__itemso they match the free radio rules' specificity and win on source order..radio-button-group__indicator— The selection mark, absolutely positioned--spacing * 3.5from the top and the inline end. It also zeroes themargin-top: 3pxthe free.radio__controlcarries to sit on the first line of a plain label, which here would be 3px of visible misalignment..radio-button-group__item-content— The text column.flex: 1 1 autowith a--spacing * 1gap, and — only when the item actually has an indicator —padding-inline-end: var(--radio-button-group-indicator-gutter)..radio-button-group__item-title— 0.875rem / 1.25rem at weight 500 in--foreground..radio-button-group__item-description— 0.75rem / 1rem in--muted, withtext-wrap: pretty..radio-button-group__item-icon— A--spacing * 9square in--defaultatcalc(--radius * 2), holding a--spacing * 5glyph. It repaints to--accent/--accent-foregroundwhile the item is selected.
Orientation Rules
.radio-button-group[data-orientation]— Pins the root toflex-direction: columnwith a--spacing * 2gap. React Aria writesdata-orientationfrom the underlyingorientationprop, and the free.radio-group[data-orientation="horizontal"]would otherwise turn the root into a wrapping row and put the label beside the cards. Arranging the items islayout's job; the root always stacks label, description, and list..radio-button-group .radio-button-group__list .radio-button-group__item—margin-top: 0. The free.radio-group[data-orientation="vertical"] [data-slot="radio"]puts 16px above every radio to space a stacked form, andverticalis React Aria's default, so it always applies. That selector is a class plus two attributes, so this reset needs the extra__listlink to match its specificity and win on source order.
State Rules
[data-selected="true"]on the item —border-color: --accent,background-color: --accent-soft, andbox-shadow: inset 0 0 0 1px var(--accent). The inset shadow is what takes the ring from 1px to 2px without reflowing the card, and it is a stroke rather than elevation.[data-focus-visible="true"]/:focus-visibleon the item — a two-stop outer ring built from--ring-offset-width,--background, and--focus. A selected card that is also focused keeps both rings, so "chosen" and "here" stay separately readable. On a radio group the pair usually travels together, since the arrow keys move focus and selection at once.[data-disabled="true"]/[aria-disabled="true"]on the item —cursor: --cursor-disabled. The opacity andpointer-events: nonealready come from the free.radio, and the underlying control is a genuinelydisabledinput, so the roving focus order skips it.[data-custom="true"]on the indicator — set whenIndicatoris given children. It starts atopacity: 0; scale: 0.8and reachesopacity: 1; scale: 1once[data-selected="true"]is also present, which is how a custom mark shows only while the item is selected.:is(:active, [data-pressed="true"])on the item —transition-duration: 0s, so the press-in snaps and only the release eases back on the base 150ms.
Reduced Motion
The indicator's scale is dropped — and its transition-property narrowed to opacity alone — under both [data-reduce-motion="true"] and @media (prefers-reduced-motion: reduce), the media half scoped :not(:is([data-reduce-motion="true"], [data-reduce-motion="true"] *)) so the two never double-apply. The fade survives in both: reduced motion removes the movement, not the transition. The card's own transition is colour and shadow only, so it has nothing to opt out of.
CSS Variables
--radio-button-group-item-radius— The card corner. Declared on the root asvar(--radius-2xl), the same radius the rest of the card family rounds to, which keeps the straight edge dominant on a short list row rather than reading as a pill. Note that@blakeui/stylesredefines--radius-2xlascalc(var(--radius) * 2)rather than Tailwind's fixed1rem, so it still tracks a theme that retunes the radius scale.--radio-button-group-columns— Column count for the grid layout. Declared as2on the root; thecolumnsprop sets it inline on the list.--radio-button-group-indicator-gutter— Room reserved at the end of the content column so a wrapping title clears the indicator. Declared ascalc(var(--spacing) * 7). Set it to0when the card reserves that room some other way — the Subscription Plans example above zeroes it because its price column already carries its own trailing padding, and the indicator is centred against the row rather than pinned to the corner.
Data Attributes
[data-slot="radio-button-group-indicator"],[data-slot="radio-button-group-item-content"],[data-slot="radio-button-group-item-title"],[data-slot="radio-button-group-item-description"],[data-slot="radio-button-group-item-icon"]— One per part, so any of them can be targeted without depending on its BEM class. The root and the item carry[data-slot="radio-group"]and[data-slot="radio"]from the free components they are built on.
API Reference
RadioButtonGroup
The root. A React Aria RadioGroup with role="radiogroup", so single selection, roving focus, arrow-key wrap, form value, and label association are handled for you.
| Prop | Type | Default | Description |
|---|---|---|---|
layout | "flex" | "grid" | "flex" | How the items are arranged. flex stacks them in a column; grid lays them out over columns tracks and collapses to one column below a 26rem container. |
columns | number | 2 | Column count, applied as an inline --radio-button-group-columns on the list. It is read only when layout is "grid"; passing it to a flex group is ignored rather than an error. |
label | ReactNode | — | The group's accessible name, rendered as a real label tied to the group. Omit it only when an aria-label names the group instead — a radio group with no name at all is a defect, and it is what tells a screen reader which question a set of cards answers. |
description | ReactNode | — | A supporting line rendered under the label. |
value | string | null | — | The selected value, for a controlled group. Pair it with onChange. A radio group holds one value, not a list — that is the whole difference from CheckboxButtonGroup, which is otherwise the same component. |
defaultValue | string | — | The initially selected value, for an uncontrolled group. |
onChange | (value: string) => void | — | Called with the newly selected value whenever selection changes. |
isDisabled | boolean | false | Disables every item in the group. Each one becomes a genuinely disabled input, so the whole group leaves the tab order and is announced as disabled. |
className | string | — | Merged onto .radio-button-group, alongside the layout modifier. Use labelClassName, descriptionClassName, and listClassName to reach the parts the root renders for you. |
children | ReactNode | — | The items. They are wrapped in .radio-button-group__list, which is the box the layout applies to. |
Also accepts every React Aria RadioGroup prop, including name, isRequired, isInvalid, isReadOnly, and orientation.
RadioButtonGroup.Item
One selectable card. A React Aria Radio, which renders a label around a visually-hidden input type="radio" — so the card's own text is its accessible name, the group is a single tab stop, and the arrow keys move focus and selection together.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | The value this card gives the group while it is selected. |
children | ReactNode | ((state) => ReactNode) | — | The card's content. Pass a function to receive this item's live state — isSelected, isHovered, isPressed, isFocusVisible, isDisabled, and the rest of React Aria's radio render props. |
isDisabled | boolean | false | Disables this card alone. Because the underlying input really is disabled, the roving focus order skips it and the arrow keys step to the next selectable card. |
className | string | — | Merged onto .radio-button-group__item. Tailwind utilities land in the utilities layer, so they win over the component's own rules — which is how the Payment method cards keep a brand logo its own colour instead of letting the icon square repaint to the accent on selection. |
Also accepts every React Aria Radio prop.
RadioButtonGroup.Indicator
The card's selection mark, pinned to the top-right. It is aria-hidden: the checked state already lives on the input, so the indicator's job is to keep selection from being carried by colour alone.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | A custom mark, shown only while the item is selected. Left empty, the part renders the free radio — a control ring that is visible at rest and fills with a dot on selection. Omit the whole part for a card with no mark at all. |
className | string | — | Merged onto .radio-button-group__indicator. |
Also accepts every native span attribute.
RadioButtonGroup.ItemContent
The card's text column.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Normally ItemTitle and ItemDescription, though a card that needs a price or a chip can put arbitrary markup here — the Subscription Plans example does. |
className | string | — | Merged onto .radio-button-group__item-content. |
Also accepts every native div attribute.
RadioButtonGroup.ItemTitle
The card's primary label. Rendered as a span, so it stays part of the radio's accessible name rather than introducing a heading.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The label text. |
className | string | — | Merged onto .radio-button-group__item-title. |
Also accepts every native span attribute.
RadioButtonGroup.ItemDescription
The card's supporting line. Also a span, and also part of the accessible name — which is why it should read as more of the option, not as separate help text.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The secondary text. It wraps with text-wrap: pretty rather than truncating. |
className | string | — | Merged onto .radio-button-group__item-description. |
Also accepts every native span attribute.
RadioButtonGroup.ItemIcon
The card's leading glyph square. aria-hidden, because the card is already named by its text and an icon that also announced would name the radio twice.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The glyph. An svg is sized to --spacing * 5 automatically. |
className | string | — | Merged onto .radio-button-group__item-icon. |
Also accepts every native span attribute.
Number Stepper
A typeable numeric field with a decrement and an increment button attached — a segmented pill whose value rolls, with every step, clamp and format behaviour taken from React Aria.
Rich Text Editor
A JSON-first rich text editor built on Tiptap, with a real ARIA toolbar, a soft character budget, and a Tab key that always lets you leave.