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 />| State | Ring | Dot | Hover |
|---|---|---|---|
| Unselected | 1px --color-grey-900 | none | --color-primary-100 fill inside the ring |
| Selected | 1px --color-primary-500 | --color-primary-500 | --color-primary-100 fill inside the ring |
| Focus (keyboard) | --shadow-focus ring | ||
| Disabled, unselected | 1px --color-grey-300, white fill | none | none |
| Disabled, selected | 1px --color-primary-300 | --color-primary-300 | none |
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
| Prop | Type | Default | Notes |
|---|---|---|---|
label | ReactNode | "" | Rendered as the <legend>. Leave it empty only if a heading right above names the group. |
name | string | "" | Required in practice. Must be unique on the page. |
options | { value: string, label: ReactNode, disabled?: boolean }[] | [] | |
value | string | "" | The selected option's value. |
onChange | (value: string) => void | Called with the picked option's value. | |
disabled | boolean | false | Disables every option. |
className | string | Added to the <fieldset>. Use for layout only. |
RadioGroup does not pass any other props through, so id, aria-describedby and data-* are dropped.
Radio
| Prop | Type | Default | Notes |
|---|---|---|---|
label | ReactNode | "" | Text after the ring. When empty, pass aria-label. |
name | string | "" | Same value for every radio in a group. |
value | string | "" | |
checked | boolean | false | Controlled. |
onChange | (event) => void | Receives the native change event. | |
disabled | boolean | false | |
className | string | Added 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>withrole="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.