Forms

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

Workspace planChange or cancel at any time.

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

Billing cycle

Next invoice: $24.00 per seat.

Custom Indicator

Deploy target

Delivery And Payment

Delivery method
Payment method

Disabled Group

Instance sizeResizing is locked while the current migration finishes.

Disabled Item

A disabled card leaves the roving focus order, so the arrow keys step straight past it to the next selectable option.

Compute tier

Grid Layout

Primary channel

Icon Cards

Choose your workspace plan

No Indicator

Optimise for

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.

Invite as

Subscription Plans

Choose your planOne plan per workspace. Switch whenever you like.

Prices exclude VAT. Change or cancel at any time.

Compare every feature

With Icons

Deploy strategy

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.

Seat preference

CSS Classes

Base Class

  • .radio-button-group — The root, which is also the free .radio-group. It declares the three custom properties below and carries container-type: inline-size so 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 * 3 gap.
  • .radio-button-group--grid — The list becomes display: grid over repeat(var(--radio-button-group-columns), minmax(0, 1fr)) with the same gap. Below a 26rem container it collapses to a single column.

Element Classes

  • .radio-button-group__label — The group's <label>, with --spacing * 0.5 of 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 to flex-start, --spacing * 4 of padding, 1px solid --border, border-radius: var(--radio-button-group-item-radius), and box-shadow: none — bordered-flat, like every card-family surface. Its rules are written as .radio-button-group .radio-button-group__item so they match the free radio rules' specificity and win on source order.
  • .radio-button-group__indicator — The selection mark, absolutely positioned --spacing * 3.5 from the top and the inline end. It also zeroes the margin-top: 3px the free .radio__control carries 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 auto with a --spacing * 1 gap, 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, with text-wrap: pretty.
  • .radio-button-group__item-icon — A --spacing * 9 square in --default at calc(--radius * 2), holding a --spacing * 5 glyph. It repaints to --accent / --accent-foreground while the item is selected.

Orientation Rules

  • .radio-button-group[data-orientation] — Pins the root to flex-direction: column with a --spacing * 2 gap. React Aria writes data-orientation from the underlying orientation prop, 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 is layout's job; the root always stacks label, description, and list.
  • .radio-button-group .radio-button-group__list .radio-button-group__itemmargin-top: 0. The free .radio-group[data-orientation="vertical"] [data-slot="radio"] puts 16px above every radio to space a stacked form, and vertical is React Aria's default, so it always applies. That selector is a class plus two attributes, so this reset needs the extra __list link to match its specificity and win on source order.

State Rules

  • [data-selected="true"] on the item — border-color: --accent, background-color: --accent-soft, and box-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-visible on 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 and pointer-events: none already come from the free .radio, and the underlying control is a genuinely disabled input, so the roving focus order skips it.
  • [data-custom="true"] on the indicator — set when Indicator is given children. It starts at opacity: 0; scale: 0.8 and reaches opacity: 1; scale: 1 once [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 as var(--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/styles redefines --radius-2xl as calc(var(--radius) * 2) rather than Tailwind's fixed 1rem, so it still tracks a theme that retunes the radius scale.
  • --radio-button-group-columns — Column count for the grid layout. Declared as 2 on the root; the columns prop 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 as calc(var(--spacing) * 7). Set it to 0 when 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.

PropTypeDefaultDescription
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.
columnsnumber2Column 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.
labelReactNodeThe 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.
descriptionReactNodeA supporting line rendered under the label.
valuestring | nullThe 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.
defaultValuestringThe initially selected value, for an uncontrolled group.
onChange(value: string) => voidCalled with the newly selected value whenever selection changes.
isDisabledbooleanfalseDisables 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.
classNamestringMerged onto .radio-button-group, alongside the layout modifier. Use labelClassName, descriptionClassName, and listClassName to reach the parts the root renders for you.
childrenReactNodeThe 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.

PropTypeDefaultDescription
valuestringThe value this card gives the group while it is selected.
childrenReactNode | ((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.
isDisabledbooleanfalseDisables 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.
classNamestringMerged 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.

PropTypeDefaultDescription
childrenReactNodeA 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.
classNamestringMerged onto .radio-button-group__indicator.

Also accepts every native span attribute.

RadioButtonGroup.ItemContent

The card's text column.

PropTypeDefaultDescription
childrenReactNodeNormally ItemTitle and ItemDescription, though a card that needs a price or a chip can put arbitrary markup here — the Subscription Plans example does.
classNamestringMerged 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.

PropTypeDefaultDescription
childrenReactNodeThe label text.
classNamestringMerged 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.

PropTypeDefaultDescription
childrenReactNodeThe secondary text. It wraps with text-wrap: pretty rather than truncating.
classNamestringMerged 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.

PropTypeDefaultDescription
childrenReactNodeThe glyph. An svg is sized to --spacing * 5 automatically.
classNamestringMerged onto .radio-button-group__item-icon.

Also accepts every native span attribute.

On this page