Radio Button
Exactly one of a known set. Never fewer than two, rarely more than six — and the card variant for choices the user needs help making.
Also called Radio Group, Segmented Control, Content Switcher, Option Button — in this system all of them are Radio Button.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Radio cards
For high-stakes choices where each option needs explaining. The whole card is the target, and the selected state uses a border plus a tint rather than just the dot.
Segmented control
The same semantics in a fraction of the space. Two to five very short labels, no descriptions, and never for anything destructive — there is no confirmation step.
Vertical vs horizontal
Vertical is the default: one left edge, one fixation per option. Horizontal only works when the labels are short enough that the box–label pairing stays unambiguous.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
A legend, an optional group description, then the options. The legend is announced before every option, which is why a styled div is not a substitute for a fieldset.
- Legend13px / 540, 10px above
A real <legend>. It gives the group a name and is read before each option, so "Automatic" becomes "Deployment, Automatic".
- Dot size18px outer, 8px inner
The selected state is a 5px border rather than a nested circle, so there is no extra element to keep aligned and no sub-pixel seam between ring and fill.
- ShapeFully round
Round means "one of these". This is the strongest shape convention in forms and it should never be broken for visual consistency.
- Option spacing10px between rows
Tight enough that the options read as one group. The gap to the next group is 32px — more than 3× — so the boundary is unambiguous.
- Tab behaviourOne stop for the group
Only the selected radio is tabbable; arrow keys move within. Native gives you this automatically as long as the options share a name.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-field | — | Unselected fill — a control goes above its container, never below it |
| --ds-border-strong | — | Unselected border |
| --ds-accent | — | Selected ring |
| --ds-accent-subtle | — | Hover fill, selected card background |
| --ds-accent-border | — | Selected card border |
| --ds-focus-ring | — | Focus outline |
Spacing
| Token | Value | Used for |
|---|---|---|
| gap | Dot to label | |
| row gap | Between options | |
| card padding | RadioCard |
Radius
| Token | Value | Used for |
|---|---|---|
| full | — | Dot |
| --radius-lg | RadioCard corners |
Motion
| Token | Value | Used for |
|---|---|---|
| duration | Border-width and colour transition |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Label gap | Type | Min width | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|---|---|---|
| Small | 16px | — | — | 10px | 13px | — | — | 44px (row) | Dense filter panels and table toolbars. |
| Medium | 18px | — | — | 10px | 13px | — | — | 44px (row) | The default in every form. |
| With description | 18px | — | — | 10px | 13px / 12px | — | 60ch | — | When an option needs a sentence of explanation. |
| Card | auto | 14px | 12px | — | — | 200px | — | — | Plans, billing intervals, anything with a real trade-off. |
| Segmented | 32px | 2px track | 8px / 6px | — | — | — | — | — | 2–5 short labels where space is tight and the change is instant. |
<fieldset><legend>Deployment</legend>…<input type="radio" /> × 3 — three groups of oneNot a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The unselected border must reach 3:1 against the surface. It is the only thing indicating the control exists.
- The selected ring must be distinguishable from the unselected border by more than colour — ours changes from a 1px border to a 5px ring, which survives greyscale.
- A selected RadioCard uses a border, a tint and the dot together. Any one alone is too subtle at a glance.
Keyboard
| Tab | Enters the group at the selected option, or the first if none is selected. One stop for the whole group. |
| ↑ / ← | Moves to the previous option and selects it. Wraps to the end. |
| ↓ / → | Moves to the next option and selects it. Wraps to the start. |
| Space | Selects the focused option if it is not already selected. |
| Tab (again) | Leaves the group entirely. It does not step through the remaining options. |
Screen readers
- Announced as "Automatic, radio button, selected, 1 of 3". The position in the set comes from the shared name.
- Selecting a radio with arrow keys changes the value immediately. If that triggers an expensive action, use Space-to-confirm semantics instead — or reconsider radios.
- A segmented control built from buttons must announce as a radiogroup, or it reads as three unrelated buttons.
Focus & touch
- The ring sits on the dot for standard radios and around the whole card for RadioCards, because on a card the entire surface is the target.
- The clickable row is at least 44px on coarse pointers. RadioCards are naturally larger and are the better mobile pattern when the choice matters.
| Attribute | Applied to | Notes |
|---|---|---|
| name | Every input in the group | The shared name is what creates the group. Without it, nothing else works. |
| <fieldset> + <legend> | The group | Provides the group name. The legend is announced before every option. |
| role="radiogroup" | A custom implementation | Only needed when not using native inputs — for example a segmented control built from buttons. |
| aria-checked | Custom radios | Native inputs handle this. A segmented control built from buttons must set it explicitly. |
| aria-describedby | The input | Points at the per-option description. |
| tabindex | A roving implementation | 0 on the selected option, −1 on the rest. Native does this for you. |
Example usage
1import { Radio, RadioGroup, RadioCard, Segmented } from '@/ui/Toggle'23// Standard group. The shared name is what makes it a group.4<RadioGroup legend="Deployment" description="Applies to every branch.">5 {options.map((o) => (6 <Radio7 key={o.value}8 name="deployment" // identical across the group9 value={o.value}10 checked={value === o.value}11 onChange={() => setValue(o.value)}12 label={o.label}13 description={o.description}14 />15 ))}16</RadioGroup>1718// Cards, for choices that need explaining19<Stack gap="sm">20 {plans.map((p) => (21 <RadioCard22 key={p.value}23 name="plan"24 value={p.value}25 checked={plan === p.value}26 onChange={() => setPlan(p.value)}27 label={p.label}28 description={p.description}29 icon={p.icon}30 badge={p.recommended && <Badge tone="accent" size="sm">Recommended</Badge>}31 />32 ))}33</Stack>3435// Segmented: same semantics, a fraction of the space36<Segmented37 value={range}38 onChange={setRange}39 aria-label="Date range"40 options={[41 { value: 'day', label: 'Day' },42 { value: 'week', label: 'Week' },43 { value: 'month', label: 'Month' },44 ]}45/>Framework-free HTML
<fieldset class="ds-radio-group">
<legend class="ds-radio-group__legend">Deployment</legend>
<div class="ds-radio">
<input class="ds-radio__input" type="radio" id="auto"
name="deployment" value="auto" checked
aria-describedby="auto-desc" />
<label class="ds-radio__label" for="auto">Automatic</label>
<p class="ds-radio__desc" id="auto-desc">Deploy on every merge to main.</p>
</div>
<div class="ds-radio">
<input class="ds-radio__input" type="radio" id="manual"
name="deployment" value="manual" />
<label class="ds-radio__label" for="manual">Manual</label>
</div>
</fieldset>CSS
.ds-radio__input {
appearance: none;
inline-size: 18px;
block-size: 18px;
border: 1px solid var(--ds-border-strong);
border-radius: 999px; /* round means "one of these" */
background: var(--ds-field);
transition: border 120ms var(--ease-standard),
background-color 120ms var(--ease-standard);
}
.ds-radio__input:hover:not(:disabled) {
border-color: var(--ds-accent);
background: var(--ds-accent-subtle);
}
/* The dot is a thick border, not a nested element — nothing to align,
and no sub-pixel seam between the ring and the fill. */
.ds-radio__input:checked {
border: 5px solid var(--ds-accent);
background: var(--ds-field);
}
.ds-radio__input:focus-visible {
outline: 2px solid var(--ds-focus-ring);
outline-offset: 2px;
}
/* Card variant: the whole surface is the target */
.ds-radio-card {
display: flex;
gap: 12px;
padding: 14px;
border: 1px solid var(--ds-border);
border-radius: var(--radius-lg);
cursor: pointer;
}
.ds-radio-card:has(:checked) {
border-color: var(--ds-accent);
background: var(--ds-accent-subtle);
box-shadow: 0 0 0 1px var(--ds-accent); /* 2px edge without reflow */
}Component API
Radio
| Prop | Type | Default | Description |
|---|---|---|---|
| name* | string | — | Must be identical across the group. This is what creates the group. |
| label | ReactNode | — | Real <label for>. Extends the hit area. |
| description | ReactNode | — | Second line, wired with aria-describedby. |
| size | 'sm' | 'md' | 'md' | 16px or 18px dot. |
RadioGroup
| Prop | Type | Default | Description |
|---|---|---|---|
| legend* | string | — | Rendered as a real <legend>. Announced before every option. |
| description | string | — | Group-level help, below the legend. |
| orientation | 'vertical' | 'horizontal' | 'vertical' | Horizontal only for two or three short labels. |
RadioCard
| Prop | Type | Default | Description |
|---|---|---|---|
| icon | ReactNode | — | Leading glyph, aligned with the label. |
| badge | ReactNode | — | Inline badge — "Recommended", "Most popular". |
| checked* | boolean | — | Drives the border, tint and dot together. |
Segmented
| Prop | Type | Default | Description |
|---|---|---|---|
| options* | { value, label, icon? }[] | — | Two to five. Keep the labels short. |
| fullWidth | boolean | false | Distributes segments evenly across the container. |
Professional tips
- If you catch yourself adding a seventh radio, switch to a select. The threshold is about scanning cost, not screen space.
- For pricing, put the recommended option in the middle and badge it. Users anchor on the middle option, and the badge makes an implicit recommendation explicit.
- An "Other" radio that reveals a text field should reveal it inline, directly beneath, and focus it automatically on selection.
- When a radio changes something expensive — a plan, a region, a data source — do not apply it on selection. Selection sets intent; a Save button applies it.
Performance
- Radio groups are cheap. The only real cost is re-rendering the whole group on every change — memoise the option rows if the group is large or the descriptions are complex.
- RadioCards with images should lazy-load them; a plan picker with four screenshots is otherwise four blocking requests above the fold.
- A segmented control animating its active pill should animate transform, not left or width, or it drops frames on every switch.
Common mistakes
- Forgetting the shared name, which silently breaks exclusivity and arrow-key navigation.
- Using a div with a click handler, which loses arrow keys, group semantics and form submission.
- Making a radio deselectable "for convenience". It breaks the mental model — if a value can be absent, that is a checkbox or an explicit "None" option.
- Putting a radio group inside a checkbox-styled container, so the shape says "many" and the behaviour says "one".
- Selecting on arrow key while also firing a network request, so arrowing through five options fires five requests.
Real-world recommendations
- For plan and pricing pickers, cards convert measurably better than plain radios. The extra space buys room for the reason to choose.
- On mobile, three or more radios with descriptions become a long scroll. Consider a bottom sheet with the same semantics instead.
- Track which default users change most often. A default that is overridden 70% of the time is the wrong default.
- Segmented controls should hold their choice across sessions when they represent a view preference. Resetting to the first tab on every visit is a small, daily annoyance.