RadioGroup
One choice out of a few, all in view. Rows for settings, cards for the big choices that shape what follows. Built on the vendored Base UI radio group.
Playground
import { RadioCard, RadioGroup } from "@/components/shared/radio-group";
<RadioGroup aria-label="Business type" defaultValue="barber" className="grid-cols-2 gap-2">
<RadioCard value="barber" label="Barbershop" description="Cuts, fades and beard trims" />
<RadioCard value="hair" label="Hair salon" description="Cuts, colour and styling" />
<RadioCard value="nails" label="Nail studio" description="Manicures, gels and nail art" />
<RadioCard value="tattoo" label="Tattoo studio" description="Sessions, consultations and touch-ups" />
</RadioGroup>Rows
Give an item a label and the whole row becomes its target: an empty ink-tint ring, black with a white dot once chosen.
import { RadioGroup, RadioGroupItem } from "@/components/shared/radio-group";
<RadioGroup aria-label="Reminder" defaultValue="24h">
<RadioGroupItem value="24h" label="24 hours before" />
<RadioGroupItem value="2h" label="2 hours before" />
<RadioGroupItem value="none" label="No reminder" />
</RadioGroup>With descriptions
A description adds a quieter line under the label. It is read after the option's name, not as part of it.
import { RadioGroup, RadioGroupItem } from "@/components/shared/radio-group";
<RadioGroup aria-label="Cancellation policy" defaultValue="moderate">
<RadioGroupItem
value="flexible"
label="Flexible"
description="Free cancellation up to 2 hours before"
/>
…
</RadioGroup>Cards
RadioCard is the whole card as one target — for the few choices that shape what comes next, like the kind of business in onboarding. The chosen card gets a soft ink wash, its edge melting into it; focus rings the card. Lay the cards out on the group with className, and name the group from its legend.
import { RadioCard, RadioGroup } from "@/components/shared/radio-group";
import { FieldLegend, FieldSet } from "@/components/shared/field";
<FieldSet>
<FieldLegend id="business-legend">What kind of business is it?</FieldLegend>
<RadioGroup
aria-labelledby="business-legend"
value={vertical}
onValueChange={pickVertical}
className="grid-cols-2 gap-2"
>
<RadioCard value="barber" label="Barbershop" description="Cuts, fades and beard trims" />
<RadioCard value="hair" label="Hair salon" description="Cuts, colour and styling" />
…
</RadioGroup>
</FieldSet>In a row
Short, parallel options can sit on one line: switch the group to flex. Arrow keys move between them either way.
import { RadioGroup, RadioGroupItem } from "@/components/shared/radio-group";
<RadioGroup
aria-label="Slot length"
defaultValue="30 min"
className="flex flex-wrap gap-x-5 gap-y-2"
>
<RadioGroupItem value="15 min" label="15 min" />
…
</RadioGroup>Disabled
Disable the whole group, or single options — and say why next to them, so a greyed-out choice isn't a mystery.
import { RadioGroup, RadioGroupItem } from "@/components/shared/radio-group";
import { FieldDescription, FieldLegend, FieldSet } from "@/components/shared/field";
<FieldSet>
<FieldLegend id="payment-legend" variant="label">Payment at booking</FieldLegend>
<RadioGroup aria-labelledby="payment-legend" defaultValue="venue">
<RadioGroupItem value="venue" label="Pay at the venue" />
<RadioGroupItem value="deposit" label="20% deposit" disabled />
<RadioGroupItem value="full" label="Pay in full" disabled />
</RadioGroup>
<FieldDescription>Connect payments to take deposits.</FieldDescription>
</FieldSet>API reference
RadioGroup
| Prop | Type | Default | Description |
|---|---|---|---|
| value | Value | — | Controlled choice. The group is typed by it, so onValueChange hands back the same type. |
| defaultValue | Value | — | Initial choice when uncontrolled. |
| onValueChange | (value: Value, details) => void | — | Called with the chosen item's value. |
| disabled | boolean | false | Disables every item. |
| name | string | — | Submits the choice with a form. |
| aria-labelledby | string | — | Point it at a FieldLegend's id — or use aria-label when there's no visible heading. |
| className | string | — | Layout: a stacked grid with a 12px gap by default; grid-cols-2 gap-2 for cards, flex for a row. |
RadioGroupItem, RadioCard
| Prop | Type | Default | Description |
|---|---|---|---|
| value | Value | — | What the group's value becomes when chosen. |
| label | ReactNode | — | Visible name; the text is part of the target. Required on RadioCard. Without it, RadioGroupItem needs aria-label. |
| description | ReactNode | — | A quieter line under the label, linked by aria-describedby. |
| disabled | boolean | false | Dims the option and skips it; its text goes muted. |
| className | string | — | Layout for the outermost element: the row or card when labelled, else the radio. |
Everything else passes through to the Base UI radio group and radio (readOnly, required, inputRef, …).
Guidelines
- Use a RadioGroup for two to five options worth seeing side by side. More than that, or options people don't need to compare, belong in a Select.
- Preselect the sensible default — the most common answer, or the safest. Leave the group empty only when there is no honest default and the choice must be made on purpose.
- Cards are for the few choices that shape what follows — the kind of business, a plan. Settings and preferences stay as rows.
- Keep labels short and parallel (“24 hours before”, “2 hours before”); move the detail into
descriptioninstead of growing the label. - Name the group — a FieldLegend it points at with
aria-labelledby, or anaria-label. - A radio can't be unset. For a single on/off that takes effect at once, use a Switch.