Design system

Radio

Picks exactly one option from a short list where every option stays visible.

import { Radio, RadioGroup } from "@/ui";

Use RadioGroup for a labelled, stacked list of options. Use Radio on its own only when the layout needs something RadioGroup cannot do, such as a row of options.

Usage

Pass options as { value, label } objects, the selected value, and a name that is unique on the page. onChange receives the picked option's value, so a state setter can go straight in.

const [model, setModel] = useState("first");

<RadioGroup
  label="Attribution model"
  name="attribution-model"
  value={model}
  onChange={setModel}
  options={[
    { value: "first", label: "First touch" },
    { value: "last", label: "Last touch" },
    { value: "linear", label: "Linear, equal credit to every touch" },
  ]}
/>

Always pass name. It defaults to "", and without it the browser does not treat the radios as one group: Tab stops on every option and the arrow keys do not move between them.

Disabled options

Add disabled: true to an option to show it but stop people picking it. Say why in the label, or next to the group. disabled on the RadioGroup itself disables every option.

<RadioGroup
  label="Domain"
  name="page-domain"
  value={domain}
  onChange={setDomain}
  options={[
    { value: "pages.petavue.com", label: "pages.petavue.com · Petavue's domain" },
    { value: "go.yourcompany.com", label: "go.yourcompany.com · connect a domain first", disabled: true },
  ]}
/>

Options in a row

RadioGroup always stacks its options. For two or three short options side by side, lay out single Radios yourself inside a <fieldset> with a <legend>, and give them the same name. Reset the browser's default <fieldset> border and padding, and style the legend 14px medium to match RadioGroup. Unlike RadioGroup, Radio's onChange receives the native event.

<fieldset>
  <legend>Compare spend over</legend>
  <div style={{ display: "flex", gap: 24 }}>
    {PERIODS.map((p) => (
      <Radio
        key={p.value}
        name="spend-period"
        value={p.value}
        label={p.label}
        checked={period === p.value}
        onChange={() => setPeriod(p.value)}
      />
    ))}
  </div>
</fieldset>

States

<Radio label="Unselected" checked={false} />
<Radio label="Selected" checked />
<Radio label="Unselected, disabled" checked={false} disabled />
<Radio label="Selected, disabled" checked disabled />
StateRingDotHover
Unselected1px --color-grey-900none--color-primary-100 fill inside the ring
Selected1px --color-primary-500--color-primary-500--color-primary-100 fill inside the ring
Focus (keyboard)--shadow-focus ring
Disabled, unselected1px --color-grey-300, white fillnonenone
Disabled, selected1px --color-primary-300--color-primary-300none

The control takes a 16px square; the visible ring is 12px across and the dot 6px. Option labels are 14px regular --color-text-primary (--color-text-disabled when disabled), 8px from the ring. In a RadioGroup the legend is 14px medium and options are 10px apart. The CSS sets a 12px gap on the group, but a <legend> is not laid out as a flex item, so in practice the legend sits directly on the first option with no space between them.

There is no error state. If a choice is required, pick a sensible default so the group is never empty.

Props

RadioGroup

PropTypeDefaultNotes
labelReactNode""Rendered as the <legend>. Leave it empty only if a heading right above names the group.
namestring""Required in practice. Must be unique on the page.
options{ value: string, label: ReactNode, disabled?: boolean }[][]
valuestring""The selected option's value.
onChange(value: string) => voidCalled with the picked option's value.
disabledbooleanfalseDisables every option.
classNamestringAdded to the <fieldset>. Use for layout only.

RadioGroup does not pass any other props through, so id, aria-describedby and data-* are dropped.

Radio

PropTypeDefaultNotes
labelReactNode""Text after the ring. When empty, pass aria-label.
namestring""Same value for every radio in a group.
valuestring""
checkedbooleanfalseControlled.
onChange(event) => voidReceives the native change event.
disabledbooleanfalse
classNamestringAdded to the wrapping <label>.

Any other prop on Radio (aria-*, ref) is passed to the <input type="radio">.

Accessibility

  • Each option is a native radio input, visually hidden, inside a <label>, so the label text is clickable and read out.
  • RadioGroup renders a <fieldset> with role="radiogroup" and the label as its <legend>, so screen readers read the question before the options.
  • With a shared name, Tab moves into the group once, landing on the selected option, and the arrow keys move and select within it.
  • The focus ring shows on keyboard focus only (:focus-visible).

Do and don't

  • Do use radios for two to five options that people should compare before choosing.
  • Do order options in a logical sequence: shortest to longest period, or most to least common.
  • Don't use a single radio on its own. For one yes or no option, use a Checkbox or a Toggle.
  • Don't use radios for six or more options. Use a Dropdown.

On this page