Radio group
Mutually exclusive options, composed with Field. Inner mark morphs square to circle.
Radio group asks for one choice out of a few options that are all in view, like a row density
or a plan, and choosing one clears the others. Each RadioGroupItem is a 20px circle, and the
mark inside spins up from a small square into a filled circle when it is chosen. An item has no
label of its own, so pair it with Field, and reach for
RadioGroupCard when an option needs a line of explaining.
Usage
import { Field, FieldLabel, FieldLegend, FieldRow, FieldSet } from '@/components/ui/field';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
<FieldSet>
<FieldLegend variant="label">Row density</FieldLegend>
<RadioGroup value={density} onValueChange={setDensity}>
<Field>
<FieldRow>
<RadioGroupItem value="comfortable" id="density-comfortable" />
<FieldLabel htmlFor="density-comfortable">Comfortable</FieldLabel>
</FieldRow>
</Field>
<Field>
<FieldRow>
<RadioGroupItem value="compact" id="density-compact" />
<FieldLabel htmlFor="density-compact">Compact</FieldLabel>
</FieldRow>
</Field>
</RadioGroup>
</FieldSet>Holding the choice
Pass value and onValueChange to hold the choice yourself, as in the usage above, or
defaultValue to let the group keep it. Values are strings. Choosing the option that is
already chosen does nothing, so once a reader picks one they cannot go back to none. Leave
value and defaultValue unset, or pass value={null}, to start with no choice, and add an
option like None when clearing is a real answer.
disabled on RadioGroup turns off every option, and on a RadioGroupItem only that one.
Inside a <form>, each option renders a hidden native radio with the group's name, so the
form sends the chosen value under name. required goes on those radios too, and a form
reset puts back the choice the group started with.
A description under each option
FieldRow align="start" with a FieldContent stacks a description under the label, and the
circle sits on the first line, because the label's line is 20px tall like the circle. Field
does not connect the description to the option, so give it an id and point the item's
aria-describedby at it.
Standard spacing for most use cases.
More space between elements.
Minimal spacing for dense layouts.
Naming the group
Wrap the group in FieldSet with a FieldLegend, so the <fieldset> around the options is
named by its legend and a screen reader says what the choice is about as focus enters it. The
legend is text-lg by default, and variant="label" draws it at label size, as in the usage
above. Without a fieldset, give RadioGroup an aria-labelledby that points at a visible
heading, or an aria-label.
Cards
RadioGroupCard puts an option on a bordered card with a title and a line of detail, for
choices that need explaining, like where recipients come from. The card is the option's label,
so a click anywhere on it chooses it. It takes a RadioGroupItem's props (value, disabled)
plus title and description, and reads its own checked and disabled state, so there is
nothing to pass down. A chosen card fills with bg-selected, an unchosen one fills with
bg-fill under the pointer, and a disabled one dims. The title labels the radio and the
description describes it. Lay the cards out on the group, since RadioGroup is a grid with a
6px gap.
<RadioGroup value={source} onValueChange={setSource} className="grid gap-3 sm:grid-cols-2">
<RadioGroupCard value="list" title="A saved list" description="Send to everyone on a list." />
<RadioGroupCard value="upload" title="Upload a CSV" description="One row per recipient." />
</RadioGroup>Props
RadioGroup and RadioGroupItem take every prop of their Radix Radio Group parts, such as
value, defaultValue, onValueChange, name, required, disabled, orientation, and
loop, and add one: RadioGroupItem takes a tone, the hue of its checked mark, which defaults to
primary, and inherit takes the tone of the surrounding scope. RadioGroupCard takes a RadioGroupItem's props, except children,
plus these.
| Prop | Type | Default | What it does |
|---|---|---|---|
title | ReactNode | Required. The option's name, in medium weight, and the radio's accessible name. | |
description | ReactNode | A line of detail under the title in text-fg-muted, read as the radio's description. | |
className | string | Styles the card. A ref still reaches the radio inside it. | |
id | string | Generated | The radio's id. |
Accessibility
RadioGrouprendersrole="radiogroup", and each item renders a button withrole="radio"andaria-checked, so assistive tech announces each option and whether it is chosen.- The group is one tab stop. Tab lands on the chosen option, or the first enabled one when nothing is chosen, and the next Tab leaves the group.
- The arrow keys move to the next or previous option and choose it on the way, wrapping from
the last back to the first. Down and Right go forward, Up and Left go back, and in a right to
left layout Left and Right swap. Set
orientationto keep to one pair of arrows, orloop={false}to stop at the ends. - Home and End move to the first and last option without choosing it, and Space chooses the option in focus. Radix stops Enter, so Enter does not.
- Each option's name comes from the
FieldLabelwhosehtmlFormatches itsid, and the group's from the legend around it or itsaria-labelledby. requiredsetsaria-requiredon the group, andaria-invalidon an item turns its ringborder-destructive.- Focus shows a 2px ring on
:focus-visibleonly, offset from the circle, in the contrast ink. On a card the ring moves to the card's edge, and the radio keeps its own name and description rather than reading the whole card. - In forced colors the ring is
ButtonText, a chosen option's ring and mark turnHighlight, a disabled option's ring turnsGrayText, and a chosen card's border turnsHighlight. A disabled option that is chosen keeps its mark, inGrayText. Focus keeps a transparent outline that the system paints. - The circle is 20px. On a coarse pointer, such as a touch screen, an invisible 44px square centered on it takes the tap too, and the label or the card is a target as well. With a mouse the extra area is off.
disabledsets the nativedisabledattribute, so the option leaves the tab order, the arrow keys pass over it, and it is reported as unavailable.- The mark takes a second to grow and turn. Under reduced motion it takes 75ms, and the ring changes color without a transition.
When to use something else
- Checkbox when any number of the options can be on at once, or for a single yes or no.
- Switch for one setting that turns on or off and takes effect the moment it flips.
- Select for one choice from a long list, where a column of radios would take more room than the choice deserves, and Combobox when the list is long enough to search.
- Toggle group with
type="single"for switching the view in a toolbar, like List or Board. It is a row of toggles that a form does not submit.
Install
shadcn add @gesso/radio-group