Gesso
Forms

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.

Row density
Week starts on

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.

Subscription plan

Yearly and lifetime plans offer significant savings.

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.

Recipients
<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.

PropTypeDefaultWhat it does
titleReactNodeRequired. The option's name, in medium weight, and the radio's accessible name.
descriptionReactNodeA line of detail under the title in text-fg-muted, read as the radio's description.
classNamestringStyles the card. A ref still reaches the radio inside it.
idstringGeneratedThe radio's id.

Accessibility

  • RadioGroup renders role="radiogroup", and each item renders a button with role="radio" and aria-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 orientation to keep to one pair of arrows, or loop={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 FieldLabel whose htmlFor matches its id, and the group's from the legend around it or its aria-labelledby.
  • required sets aria-required on the group, and aria-invalid on an item turns its ring border-destructive.
  • Focus shows a 2px ring on :focus-visible only, 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 turn Highlight, a disabled option's ring turns GrayText, and a chosen card's border turns Highlight. A disabled option that is chosen keeps its mark, in GrayText. 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.
  • disabled sets the native disabled attribute, 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

On this page