Radio buttons
components specs/components/radio-buttons.kmd
Single-select from N mutually exclusive options. Material parity (`/components/radio`). Use radio when N ≤ 5 and all options should be visible; use a dropdown/select for N ≥ 6.
When this spec applies
Primary triggers
- Implement radio button group
All triggers
- Build a single-select from a small fixed list (N ≤ 5)
- Settings page with mode picker (light/dark/auto)
- Survey question with mutually exclusive options
Specification body
Spec — Radio buttons
Facet Visual do Koder Design. Material parity: https://m3.material.io/components/radio.
Anatomy
● Selected (accent ring + accent dot)
○ Unselected (text-muted ring + empty)
- Outer ring: 20×20 px (default), 2 px border
- Inner dot: 10 px diameter,
accentfill, only visible when selected - Hit zone: 48×48 px
- Label (always paired): 12 px gap,
body-medium
R1 — Cardinality
- N options in a group: ALL must be mutually exclusive
- AT LEAST one must be selectable; user can't have zero selected after first interaction
- Default selection: indicate one as the default (sensible default for most users) OR leave none selected with a note explaining
R2 — States (visual)
| State | Outer ring | Inner dot |
|---|---|---|
| Unselected | text-muted (2 px) | — |
| Unselected + hover | text | — |
| Unselected + focused | + focus ring (2 px outline) | — |
| Selected | accent (2 px) | accent dot |
| Selected + hover | accent-strong ring | accent-strong dot |
| Selected + focused | + focus ring | accent dot |
| Disabled | 38% opacity all | 38% opacity |
| Error | error ring | error dot (if selected) |
R3 — Layout
Vertical (default):
○ Option 1
○ Option 2
● Option 3
○ Option 4
Horizontal (compact, when options are short):
○ Yes ● No ○ Maybe
Vertical preferred for accessibility (each option clearly demarcated). Use horizontal only when 2-3 short options that visually fit in container.
R4 — Group semantics
Radio buttons MUST be grouped. Group declares the mutual exclusion.
<fieldset>
<legend>Theme preference</legend>
<label><input type="radio" name="theme" value="light"> Light</label>
<label><input type="radio" name="theme" value="dark"> Dark</label>
<label><input type="radio" name="theme" value="system" checked> System</label>
</fieldset>
Native <fieldset> + <legend> is the canonical accessible
grouping. ARIA alternative: role="radiogroup" + aria-labelledby
referencing a heading.
R5 — Selection behavior
- Click on radio OR label OR row anywhere → selects
- Keyboard: arrow keys (Up/Down for vertical, Left/Right for horizontal) move within group AND select on focus
- Tab moves INTO and OUT OF the group (not between options — that's arrows)
- Selection visually + announces "Selected" via screen reader
R6 — When NOT to use radio
| Situation | Use instead |
|---|---|
| > 5 options | Dropdown / Select |
| Independent multi-select | Checkbox |
| Toggle ON/OFF | Switch (settings) or Checkbox (form) |
| Mode picker with previews | Segmented button or card grid |
| Required selection but neutral default | Radio with no default + helper text |
R7 — Default selection
Either:
- Pre-select the most likely / safe default (most users) — pre-fills
checked=trueon one option - OR pre-select nothing — forces a deliberate choice
Required pre-selection when the form must submit a value (no "null" acceptable). Optional when "no preference" is meaningful.
R8 — Per-option label content
Per foundations/ux-writing.kmd:
- Short label (≤ 32 chars typical)
- Optional secondary text below (smaller, muted) for explanation
- Avoid full sentences in labels — that's body text, not control text
● Send me weekly digest
Includes top posts + replies from people you follow.
○ Send me daily highlights
Top 5 posts each morning.
○ Don't send updates
You can re-enable any time in Settings.
R9 — Accessibility
<input type="radio">(NOT custom<div>)- Group via
<fieldset>ORrole="radiogroup" - Label association via
<label for>OR wrapped - Visible focus indicator
- Arrow key navigation native to
<input type="radio">within samename - Screen reader announces "Radio, 2 of 4, Dark, not selected"
R10 — Forbidden patterns
- ❌ Radio buttons that allow zero selection after first interaction (use checkbox for "optional" semantics)
- ❌ More than 5 radio buttons (switch to dropdown)
- ❌ Mixing radio + checkbox in same logical group (semantic contradiction)
- ❌ Radio without visible label (icon-only is rare and confusing)
- ❌ Different label widths causing column misalignment (use consistent label width or wrap)
R11 — Per-preset variation
| Preset | Treatment |
|---|---|
material3 | 20 px outer, 10 px inner, 2 px border |
ios_cupertino | Larger (24 px outer), thinner border |
windows_95 | 3D bevel ring + filled dot |
terminal_classic | ASCII ( ) / (•) |
brutalist | Sharp square radio (not round), 3 px border |
Cross-link
interaction/selection.kmd— single-select patternsinteraction/states.kmd— state visualsfoundations/ux-writing.kmd— label content stylethemes/color-roles.kmd— accent + error tokens
Requirements (testable)
Requirement: Mutual exclusion and non-empty selection {#req-radio-mutual-exclusion}
A radio group SHALL enforce that its N options are mutually exclusive so that at most one option is selected at any time, and once the user has interacted with the group it SHALL NOT be possible to return to a zero-selected state.
Scenario: Selecting a second option clears the first
- GIVEN a radio group named "theme" with options Light, Dark, and System
- AND the option Dark is currently selected
- WHEN the user selects the option Light
- THEN Light becomes the only selected option
- AND Dark becomes unselected
- AND exactly one option in the group is selected
Scenario: Selection cannot be emptied after first interaction
- GIVEN a radio group where the user has already selected one option
- WHEN the user activates that same already-selected option again
- THEN the option remains selected
- AND the group still reports exactly one selected option (never zero)
Requirement: Visual state rendering {#req-radio-states}
A radio button SHALL render its outer ring and inner dot according to its selection and interaction state: an unselected control SHALL show a text-muted 2 px ring with no inner dot, a selected control SHALL show an accent 2 px ring with an accent inner dot, a disabled control SHALL render all elements at 38% opacity, and an error control SHALL use the error ring color.
Scenario: Unselected versus selected rendering
- GIVEN a radio control in the unselected state
- WHEN the control is first rendered
- THEN its outer ring uses the
text-mutedcolor at 2 px and no inner dot is visible - WHEN the control becomes selected
- THEN its outer ring uses the
accentcolor at 2 px - AND an
accent-filled inner dot becomes visible
Scenario: Disabled control is dimmed
- GIVEN a radio control marked disabled
- WHEN the control is rendered
- THEN the outer ring and inner dot are drawn at 38% opacity
Requirement: Accessible grouping semantics {#req-radio-grouping}
Radio buttons SHALL be grouped by a native <fieldset> with a <legend>, or equivalently by role="radiogroup" with aria-labelledby referencing a heading, so that the mutual-exclusion relationship and the group name are exposed to assistive technology.
Scenario: Fieldset groups the options
- GIVEN a set of radio inputs sharing the same
nameattribute - WHEN they are rendered as a group
- THEN they are enclosed in a
<fieldset>element containing a<legend>that names the group - AND every option carries an associated visible label via
<label for>or a wrapping<label>
Requirement: Keyboard selection and navigation {#req-radio-keyboard}
Radio selection SHALL be operable by pointer and keyboard: activating a radio, its label, or its row SHALL select the option; arrow keys SHALL move focus within the group and select the focused option; and Tab SHALL move focus into and out of the group as a whole rather than between individual options.
Scenario: Arrow key moves and selects within the group
- GIVEN a vertical radio group with the second option focused and selected
- WHEN the user presses the Down arrow key
- THEN focus moves to the third option
- AND the third option becomes selected
- AND the second option becomes unselected
Scenario: Clicking the label selects the option
- GIVEN an unselected radio option paired with a text label
- WHEN the user clicks the label text
- THEN the associated radio option becomes selected
Scenario: Tab exits the group rather than stepping through options
- GIVEN a radio group with one option focused among four options
- WHEN the user presses Tab
- THEN focus leaves the radio group entirely and moves to the next focusable control outside the group
Requirement: Native input and screen-reader announcement {#req-radio-a11y}
A radio control SHALL be built from a native <input type="radio"> rather than a custom <div>, SHALL present a visible focus indicator, and SHALL announce its role, position within the group, label, and selection state to screen readers.
Scenario: Focused option is announced with position and state
- GIVEN an accessible radio group of four options with the second option unselected
- WHEN the user moves keyboard focus onto the second option
- THEN a visible focus indicator is shown on that option
- AND the screen reader announces it as a radio, "2 of 4", with its label and its not-selected state
References
specs/interaction/selection.kmdspecs/interaction/states.kmdspecs/themes/color-roles.kmd