Designer view
Radio Group
A mutually-exclusive selection control — a fieldset of radio buttons where exactly one option is selected at a time. Native `<input type="radio">` instances grouped by a shared `name` attribute, wrapped in a `<fieldset>` with `<legend>` (or `role="radiogroup"` with `aria-labelledby`). Distinct from Checkbox by enforcing single-selection mutex; distinct from Select by surfacing all options inline rather than behind a popup.
Also called Radio buttons Radios Option group
When to use
Use
For mutually-exclusive single-selection from a small bounded set (typically 2-7 options) where surfacing all options inline aids decision-making — yes/no choices, contact-method pickers, plan tier selectors, payment-method choices. Pair with a `<fieldset>` plus `<legend>` (or `role="radiogroup"` plus `aria-labelledby`) so the group has a single accessible name. Pre-select a default only when there is a meaningful canonical choice; for "must answer" decisions, leave all radios unchecked and pair with `aria-required`.
Avoid
For independent on/off toggles where multiple options can be selected simultaneously — that is `Checkbox`. For dense-mutex UI where the group represents view modes / display modes rather than a form field — that is `SegmentedControl`. For long bounded sets (more than ~7 options) where inline surface becomes overwhelming — that is `Select` (or `Combobox` for typeahead). Never use radios as toggle buttons that change behaviour without form submission — that misuses the "field with a value" semantic.
Versus related
- checkbox
`Checkbox` is a binary form field where multiple instances allow independent selection (multi-select); `Radio Group` enforces single-selection mutex via shared `name`. Decision test: can the user select more than one option simultaneously (Checkbox) or exactly one (Radio Group)? Tri-state mixed is unique to Checkbox; Radio Group has no equivalent partial state.
- segmented-control
`SegmentedControl` is a denser-mutex pattern for view- mode / display-mode toggles ("List / Grid", "Day / Week / Month") without form-field semantics; `Radio Group` is a form field whose value submits with the form. Both use `role="radiogroup"` with roving- tabindex; the difference is intent — form value (Radio Group) vs UI state (Segmented Control). The decision test: is the choice a form value (Radio Group) or a UI state (Segmented Control)?
- select
`Select` houses options behind a popup trigger, suiting long bounded sets and dense layouts; `Radio Group` surfaces all options inline, suiting short sets where seeing every option aids decision. Both produce a single-value form submission. Migrate from Radio Group to Select when option count crosses ~7 and inline density becomes noisy; migrate from Select to Radio Group when the set is short and the choice is consequential enough to deserve visible weight.
Radio Group is the canonical single-selection-from-finite-set primitive. Each radio is its own focusable element only by the canonical roving-tabindex contract (only the checked radio participates in the page tab order; arrow keys move focus and selection within the group). Native HTML `<input type="radio" name>` enforces the mutex via shared `name`; the canonical YAML documents the radio-as-sub-anatomy plus the group-level contracts (legend label, aria-required, validation surfacing). Two variants — standard inline radios for compact forms and card-style radios for prominent choice surfaces — and the divergence from Checkbox (independent toggles), Select (popup-housed), and SegmentedControl (denser-mutex without field semantics).
Figma anatomy
| Slot | Figma type | Hint |
|---|---|---|
root | frame | Auto-layout vertical (or horizontal per orientation property) frame; group wrapper |
legend | text | Group title; size matches form-section heading; weight semibold by default |
radio | frame | Auto-layout horizontal frame containing input + indicator + label; repeats per option |
input | frame | Square frame; size variant binds to size token; carries focus ring on focus-visible |
indicator | instance | Filled-dot variant inside input frame; visible when checked |
radio-label | text | Text element inline with input; size matches root size variant |
description | text | Smaller text below legend; muted color; visibility per "has description" property |
error-message | text | Error-toned text below radio set; visibility per "has error" property |
Token usage per slot
root- spacing
- gap
spacing.compact
- gap
legend- color
- foreground
color.text.primary
- foreground
- typography
- size
text.md - weight
weight.semibold
- size
radio- spacing
- gap
spacing.tight
- gap
input- radius
- corner
radius.full
- corner
- color
- border
color.border.strong
- border
indicator- color
- background
color.accent.bg
- background
radio-label- color
- foreground
color.text.primary
- foreground
- typography
- size
text.sm
- size
description- color
- foreground
color.text.muted
- foreground
- typography
- size
text.xs
- size
error-message- color
- foreground
color.text.danger
- foreground
- typography
- size
text.xs
- size
Figma ↔ Code property map
| Figma | Kind | Code | Notes |
|---|---|---|---|
Variant | Enum | variant | standard (radio circle + label) / card (selectable card; visually-hidden input retained for AT). |
Size | Enum | size | sm / md / lg. |
Orientation | Enum | orientation | vertical / horizontal. Drives layout direction; RTL flips horizontal automatically. |
Required | Boolean | required | Sets `aria-required="true"` on the group root; per-radio `required` is derived. |
Disabled | Boolean | disabled | Cascades to all children; per-radio override allowed. |
Invalid | Boolean | invalid | Pairs `aria-invalid="true"` on the group with the visual error treatment. |
Value | Text | value | Controlled selected value. The single radio whose `value` matches is checked; siblings unchecked. |
Name | Text | name | Shared HTML `name` attribute on every child input — drives native mutex. |
Legend | Text | children of `<legend>` | Group label. Visually-hidden when surrounding context already labels the group. |
Description | Text | description | Optional supporting prose, programmatically linked via `aria-describedby` on the group. |
Error Message | Text | errorMessage | Visibility bound to invalid state. |
Indicator | Slot | indicator | Decorative — filled dot inside the selected radio; empty in siblings. |
Motion
| Transition | Duration token |
|---|---|
indicatorScale | motion.duration.fast |
selectionShift | motion.duration.fast |
Internationalisation
RTL · mirroring
Radio + label inline order follows writing direction — input on the inline-start, label on the inline-end (LTR: left + right; RTL: right + left). Use `flex-direction: row` with logical properties; the browser handles the swap. Indicator dot is direction-neutral. Horizontal orientation flips the radio order — first radio sits at the inline-start (left in LTR, right in RTL); Arrow-key navigation respects the visual order so ArrowRight in RTL moves to the next radio (which is to the visual left, but logically the next in DOM order).
Text expansion
Radio labels expand 30-50% under translation; long-label horizontal orientations may need a fallback to vertical on narrow viewports. Legend text follows generic prose expansion. Reserve no fixed width on labels; let the wrapper flow and wrap to multiple lines under pressure. Card-variant radios with rich content (heading + description) need explicit min-width tokens to prevent layout collapse under expansion.
Variants, properties, states
Variants
Structurally different versions of the component.
standard card Properties
The same component, parameterised.
| Property | Type |
|---|---|
size | sm | md | lg |
orientation | vertical | horizontal |
required | boolean |
disabled | boolean |
invalid | boolean |
States
Browser/user-driven (interactive) vs. app-driven (data).
| Kind | States |
|---|---|
interactive | hoverfocus-visibleactivedisabled |
data | emptyselectedinvalid |
State transitions
| From | To | Trigger |
|---|---|---|
empty | selected | User activates a radio (Space when focused, or click on label, or Arrow key per APG roving-tabindex contract) |
selected | selected | User moves selection to a sibling (Arrow key auto-selects per APG; previous radio becomes unchecked atomically) |
invalid | selected | User makes a valid selection after a failed validation pass |
Figma↔Code mismatches
- 01 Figma
Each radio drawn as an independent component instance with its own checked state
CodeRadios share a `name` attribute; selecting one auto-deselects siblings
ConsequenceDesigners ship "Radio / Email / Checked" + "Radio / Phone / Checked" as separate components and check both in a mock; developers know the `name`-grouping enforces mutex so only one is checked at runtime. Designers may not realise that selecting any second radio in a real form deselects the first — Figma allows the impossible state.
CorrectDocument the group as the canonical unit. Figma's Radio Group component carries the value-prop binding so switching radios within the group enforces mutex visually. Code uses native `name` grouping or a controlled `value` prop on the group root.
- 02 Figma
Every radio drawn as tab-focusable
CodeRoving-tabindex — only the checked radio carries `tabindex="0"`; siblings carry `tabindex="-1"`
ConsequenceDesigners see every radio as a focusable element and sketch focus-ring states on each; developers ship the roving-tabindex contract where Tab moves over the entire group as a single stop and Arrow keys move within. The two surfaces visually disagree on what receives focus during keyboard navigation — designers may misunderstand the contract and mock per-radio Tab navigation.
CorrectDocument roving-tabindex as the canonical interaction contract in the anatomy. Figma carries a single focus treatment for the group plus per-radio focus-visible treatment for arrow-key-driven moves. Code enforces `tabindex` per the APG pattern.
- 03 Figma
Hit-target painted as the 16×16 visual circle
CodeHit-target extends to the wrapping label (≥ 24×24 effective tap area)
ConsequenceDesigners paint the visual circle at 16×16 px; developers extend the hit-target via the wrapping label and surrounding padding. The two surfaces look identical but the actual tap area differs. Tightening the gap between input and label visually breaks the WCAG 2.5.8 24×24 threshold without warning.
CorrectDocument the hit-target as the wrapping label region. Figma's component includes an invisible bounding box at ≥ 24×24 px encompassing input plus label. Code wraps input in `<label>` so the entire region activates.
- 04 Figma
Card-variant radio drawn without a visible radio circle
CodeCard variant retains the radio input (visible or visually-hidden) for AT
ConsequenceDesigners ship a card-style radio variant where only the card border and background communicate selection; developers either drop the input (breaking AT) or hide it visually but keep it semantic. Without the radio input, SR users cannot determine that the cards are a mutex group — they may read as buttons or links.
CorrectCard variant retains the radio input either visibly (small dot in a corner) or visually-hidden via `sr-only` so AT receives the role and state. Card selection is purely visual reinforcement; the radio semantic is preserved. Document the visually-hidden- input pattern in the card-variant anatomy.
Contracts
Non-negotiable contracts
APGAPG: Radio Group pattern The radio set is wrapped in `<fieldset>` with `<legend>` (or `role="radiogroup"` with `aria-labelledby`). The legend is the canonical accessible name for the entire group. Implementations that ship loose radios without group semantics lose the "this is a single decision" frame for AT.
Without group semantics, SR users hear individual radio labels but not the group context. The decision-frame ("Preferred contact method") is invisible; users may navigate radios without understanding what the choice is for. Group label is the most-load-bearing accessibility contract.
APGAPG: Radio Group keyboard interaction Roving-tabindex — only the checked radio (or first if none checked) carries `tabindex="0"`; siblings carry `tabindex="-1"`. Tab enters and exits the group as a single stop; Arrow keys move focus and selection within. Native HTML radio inputs provide this automatically; custom-rendered radios must implement explicitly.
Without roving-tabindex, keyboard users tab through every radio individually — a 7-option group requires 7 Tab presses to bypass. The group becomes a tab-stop burden rather than a single decision. Arrow-key auto- selection is the canonical interaction; users do not need Space to commit.
HTML specHTML input radio name-grouping All radios in a single group share the same `name` attribute (native HTML) or the same group `value` binding (controlled). The mutex is enforced by the group, not per-radio. Loose radios with distinct names ship as independent toggles, not a group.
Without shared `name` (or controlled `value`), selecting a second radio does not deselect the first. Multiple radios can be checked simultaneously — the mutex is broken. Users see contradictory state and form submission carries multiple values for what should be a single field.
Vocabulary drift
- HTML
`<input type="radio">`- Native HTML element. `name`-grouping enforces mutex. `required` on any one radio in the group satisfies the HTML requirement for the entire group. Default submitted value is `"on"` when no `value` attribute.
- WAI-ARIA
`role="radiogroup"` + `role="radio"` (with `aria-checked`)- WAI-ARIA radiogroup pattern. Native fieldset provides the group semantic implicitly; custom-rendered groups set `role="radiogroup"` plus `aria-labelledby` plus manage roving-tabindex.
- Material 3
Radio button- Material 3 documents per-radio specs (size, hit-target); group semantics are deferred to the consumer (Material does not ship a RadioGroup primitive in the spec itself, only in library implementations like Material UI).
- GOV.UK
Radios- GOV.UK uses plural "Radios" because the canonical usage is grouped. Inline-display variant for short two-option pairs (yes / no) plus "or" divider for "none of the above" — both compose with the canonical anatomy without extending it.
- Atlassian
Radio (single) + Radio Group (composition)- Atlassian splits Radio (single item) from Radio Group (composition) — same canonical anatomy split as the UI Anatomy reference, codified in the radio sub- anatomy slot.
Common mistakes
#radio-group-no-label
Group has no `<legend>` or `aria-labelledby`
The radio set ships without a fieldset+legend wrapper and without `aria-labelledby` on a custom radiogroup root. SR users hear individual radio names without the group context — "radio button, Email, not selected" — losing the "Preferred contact method" semantic. The group's meaning is invisible to AT.
Wrap the radios in `<fieldset>` with `<legend>` carrying the group label. For custom-rendered groups, set `role="radiogroup"` plus `aria-labelledby` referencing a heading or labelled element. The legend may be visually- hidden via `sr-only` when the group label is supplied by surrounding context, but the programmatic association is non-negotiable.
#radio-tabindex-not-roving
Every radio is in the page tab order
All radios carry `tabindex="0"` (or no tabindex on native inputs without roving-management); Tab moves focus through every radio one by one. Keyboard users cannot escape the group quickly — a 7-option group requires 7 Tab presses to bypass. Violates the APG radiogroup roving-tabindex contract.
Implement roving-tabindex — only the checked radio carries `tabindex="0"`; siblings carry `tabindex="-1"`. When no radio is checked, the first radio in DOM order is the tab-stop. Tab enters and exits the group as a single stop; Arrow keys move focus and selection within. Native HTML `<input type="radio">` provides this automatically per browser convention; custom-rendered radios need explicit management.
#radio-arrow-keys-no-wrap
Arrow keys stop at the boundary instead of wrapping
Pressing ArrowDown on the last radio does nothing (focus stuck at the boundary); pressing ArrowUp on the first radio does nothing. Users learn to press Tab to escape, but the canonical APG contract is wrap-around — last → first and first → last.
Implement wrap-around in the Arrow-key handler. ArrowDown / ArrowRight on the last radio moves to the first; ArrowUp / ArrowLeft on the first moves to the last. The wrap is APG-canonical. Toolbar-context groups may opt out (Arrow without selection is the toolbar pattern); standalone radiogroups always wrap.
#radio-no-fallback-default-with-required
Group is required but no radio is pre-selected and the user has no signal
The group carries `aria-required="true"` (or per-radio `required` attribute) but no radio is pre-selected and the form prompt does not communicate the requirement. Users may submit the form without selecting; the validation error fires only on submit. Sighted users see no asterisk; SR users hear "required" but not what the canonical answer is.
Either pre-select a sensible default radio when one exists canonically (the most common choice, the least-destructive option) or surface the required state visibly via legend prefix ("Required: Preferred contact method") plus inline validation hint. Never rely on submit-time error messaging alone — the user should know the field is required before attempting submission.
#radio-color-only-state
Selection state communicated via colour change alone
The selected radio is signalled only by a colour change of the input border or background (e.g. light grey ring → blue ring) without a visible filled dot or checkmark. Colour-blind users and users in grayscale or low-light displays cannot distinguish selected from unselected. Fails WCAG 1.4.1 (Use of Colour).
Always render the indicator dot in the selected state. The filled circle is the primary state signal; colour is secondary reinforcement. Test in grayscale (`filter: grayscale(1)`) — if the selection is invisible, the indicator is missing or under-emphasised.
Accessibility hints
| Slot | Accessibility hint | |
|---|---|---|
root | `<fieldset>` is canonical because it provides the grouping semantic and pairs with `<legend>` for the accessible name without ARIA. Custom-rendered groups use `<div role="radiogroup">` plus `aria-labelledby` referencing the legend element. The root carries `aria-required` and `aria-invalid` for validation surfaces; per-radio `required` is technically valid but only one is needed (the group-level state is the canonical surface). | |
legend | `<legend>` provides the accessible name when the root is `<fieldset>`. For custom-rendered groups, the legend element is referenced via `aria-labelledby` on the radiogroup root. Never substitute adjacent paragraph text for the legend — the programmatic association is what AT relies on. SR announces the legend on entry ("group, Preferred contact method, radio button, 1 of 3, Email"). | |
radio | Each radio is a single label region — wrap input plus text in a single `<label>` so the entire region is clickable. The input carries `role="radio"` natively; custom-rendered radios set `role` plus `aria-checked` plus tabindex manually (only the checked radio carries `tabindex="0"`; siblings carry `tabindex="-1"`). | |
input | `<input type="radio">` carries `role="radio"` implicitly. `aria-checked` is set automatically by the browser. The roving-tabindex is enforced via `tabindex="0"` on the checked radio and `tabindex="-1"` on siblings — when no radio is checked, the first radio in DOM order is the tab-stop. Custom-rendered radios must set the attributes manually plus listen for ArrowKey navigation. | |
indicator | Decorative — `aria-hidden="true"` (or rendered via SVG without `role`). The state is announced via the input's `aria-checked` value; SR users hear "radio button, selected" / "radio button, not selected", not "filled circle". | |
radio-label | SR announces label after the group context — "group, Preferred contact method, radio button, 1 of 3, Email, not selected". The label is per-radio; never collapse multiple options into a single label. For options with descriptions ("Email — Best for non-urgent updates"), compose the description as a separate per-radio description slot bound via `aria-describedby`. | |
description | Associate via `aria-describedby` on the root. SR announces description after the legend on group entry. Avoid putting required-field markers in the description; use `aria-required` on the root instead. | |
error-message | `aria-describedby` on the root includes the error- message id; `aria-invalid="true"` on the root triggers SR error announcement on group focus. The error- message element carries `aria-live="polite"` so async validation announces without forcing focus moves. |