Dev 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).

Highlight
Fig 1.1 · Radio Group · Dev view
Dev

Code anatomy

Slot Code slot Semantic
root root fieldset-or-div-radiogroup
legend legend legend
radio radio label-wrapping-input
input input input-radio
indicator indicator presentational
radio-label radio-label label-text
description description span-or-div
error-message error-message span-with-aria-live
Both

Variants, properties, states

Variants

Structurally different versions of the component.

standard card

Properties

The same component, parameterised.

PropertyType
size sm | md | lg
orientation vertical | horizontal
required boolean
disabled boolean
invalid boolean

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
hoverfocus-visibleactivedisabled
data
emptyselectedinvalid
Both

State transitions

FromToTrigger
emptyselectedUser activates a radio (Space when focused, or click on label, or Arrow key per APG roving-tabindex contract)
selectedselectedUser moves selection to a sibling (Arrow key auto-selects per APG; previous radio becomes unchecked atomically)
invalidselectedUser makes a valid selection after a failed validation pass
Dev

Cross-framework expression

FrameworkStructure mechanismVariant mechanism
Web Components A `<ui-radio-group>` host with `name`, `value`, `orientation`, `required`, `disabled` attributes plus slotted `<ui-radio>` children; group manages roving- tabindex and Arrow-key navigation; child radios reflect their value to a hidden native input for form participation. attributes (`variant="card"`, `orientation="horizontal"`, `size="md"`); CSS `:state(checked)` for selected styling; `direction: rtl` flips horizontal orientation.
React Compound component (Radix RadioGroup.Root + Item + Indicator, React Aria RadioGroup + Radio, Material UI RadioGroup, Mantine Radio.Group); group root is controlled via `value` plus `onValueChange`; child radios receive `value` prop and report selection up. props with class-variance-authority; `orientation` prop on root drives layout; `isRequired`, `isDisabled`, `isInvalid` on root cascade to children; per-radio `isDisabled` overrides for individual disabling.
Angular (signals) Angular component with input<string>('value') and input<string>('name'); content-projects Radio components via ng-content; ControlValueAccessor for ngModel/formControl integration; signal-derived selection from radio children. input<'vertical' | 'horizontal'>('orientation'); input<'standard' | 'card'>('variant'); host-binding [attr.role]="'radiogroup'" plus [attr.aria-labelledby]; signal-derived aria-required/aria-invalid from form- control state.
Vue Single-file component with v-model for selected value (string); default slot for child Radio components; PrimeVue RadioButton, Naive UI Radio.Group as third- party precedents. defineProps with literal-union types; computed property for aria-required from form-state; provide/inject for child-radio communication.
Both

Events

  1. valueChange
    Payload
    `{ value: string, previousValue: string | null }`. Fires when the selected radio changes via user interaction (click, Space when focused, Arrow key per APG roving- tabindex contract). Empty group transitions emit `previousValue: null`; selecting from one radio to another emits both values.
    Web Components
    `valueChange` CustomEvent on the host with `event.detail = { value, previousValue }`.
    React
    `onValueChange(value)` callback (Radix idiom) or `onChange(value)` (React Aria idiom).
    Angular Signals
    `output<string>('valueChange')` plus ControlValueAccessor `onChange(value)` for forms.
    Vue
    `@update:modelValue` event for v-model binding; `@change` for non-controlled usage.
  2. validityChangeoptional
    Payload
    `{ valid: boolean, message: string | null }`. Fires when the group's validity changes (e.g. required-but-empty → selected). Only canonical when consumers manage validation messages externally; native form-validation handles the internal validity state without explicit events.
    Web Components
    `validityChange` CustomEvent with `event.detail = { valid, message }`.
    React
    `onValidationChange(validity)` (React Aria) or absent in Radix (relies on consumer state).
    Angular Signals
    Absent — uses Angular forms-validators pattern; emit only when state-driven external validation is wired.
    Vue
    Absent — uses VeeValidate or composables; no canonical event emitter.
Dev

Form integration

HTML5 validation
Native Constraint Validation API surfaces `valueMissing` when the group is required and no radio is checked. `required` on a single radio in the group satisfies the HTML requirement for the entire group (browsers enforce group-level required when any one radio carries the attribute). Custom-rendered radios mirror state to a hidden native input or set `aria-invalid` on the root.
Both

Accessibility

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

Accessibility acceptance

Keyboard walk

KeysExpected
Tab (entering the group)Focus lands on the checked radio. If no radio is checked, focus lands on the first radio in DOM order. Subsequent Tab presses exit the group as a single tab stop — Tab does NOT move between radios.
ArrowDown / ArrowRight (focus inside group)Moves focus to the next radio AND selects it (auto-selection per APG canonical). Wraps from last to first. ArrowDown / ArrowRight equivalent for vertical / horizontal orientation.
ArrowUp / ArrowLeft (focus inside group)Moves focus to the previous radio AND selects it. Wraps from first to last.
Space (focus on unchecked radio)Selects the focused radio. Used when the user has moved into the group via Tab and needs to confirm selection without triggering arrow-key auto-select.
Tab (exiting the group)Moves focus to the next focusable outside the group. The radio set behaves as a single tab stop.

Screen-reader announcements

TriggerExpected
SR enters the radiogroupAnnounces "group, [legend text]" on group entry, then the focused radio's role + position + label + state — "radio button, 1 of 3, Email, selected" or "not selected". The 1-of-N position counter aids orientation.
SR encounters arrow-key navigationAnnounces the new selection plus state — "radio button, 2 of 3, Phone, selected". The previously- selected radio is implicitly unchecked; SR may or may not announce the deselection (varies by SR).
SR encounters group with validation errorAnnounces "group, [legend], invalid entry, [error message]" via aria-invalid + aria-describedby on the root. Per-radio state continues to announce normally.

axe-core rules to assert

  • aria-allowed-attr
  • aria-required-attr
  • aria-valid-attr-value
  • label
  • color-contrast

Same data as JSON for direct ingestion into Playwright + @axe-core/playwright or Jest + jest-axe: /api/components/radio-group/a11y-fixture.json

Both

Contracts

Non-negotiable contracts

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

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

  3. 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.
Dev

Common mistakes

Blocker

#radio-group-no-label

Group has no `<legend>` or `aria-labelledby`

Problem

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.

Fix

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.

Blocker

#radio-tabindex-not-roving

Every radio is in the page tab order

Problem

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.

Fix

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.

Major

#radio-arrow-keys-no-wrap

Arrow keys stop at the boundary instead of wrapping

Problem

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.

Fix

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.

Major

#radio-no-fallback-default-with-required

Group is required but no radio is pre-selected and the user has no signal

Problem

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.

Fix

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.

Major

#radio-color-only-state

Selection state communicated via colour change alone

Problem

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

Fix

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.

Figma↔Code mismatches
  1. 01
    Figma

    Each radio drawn as an independent component instance with its own checked state

    Code

    Radios share a `name` attribute; selecting one auto-deselects siblings

    Consequence

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

    Correct

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

  2. 02
    Figma

    Every radio drawn as tab-focusable

    Code

    Roving-tabindex — only the checked radio carries `tabindex="0"`; siblings carry `tabindex="-1"`

    Consequence

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

    Correct

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

  3. 03
    Figma

    Hit-target painted as the 16×16 visual circle

    Code

    Hit-target extends to the wrapping label (≥ 24×24 effective tap area)

    Consequence

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

    Correct

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

  4. 04
    Figma

    Card-variant radio drawn without a visible radio circle

    Code

    Card variant retains the radio input (visible or visually-hidden) for AT

    Consequence

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

    Correct

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