Single-choice selection control: a round 24 box carrying a 12 dot when selected, wrapped in a 48 touch target. Used in forms, settings, and any list where exactly one option is chosen.
Overview
RadioButton picks exactly one option out of several. One size, one shape — every form it ships in is below.
States
Four states, matching the Figma variant matrix State x Disabled. Unselecting through the control is not a state of its own — an option leaves Selected only when another option in the group is selected.
-
1
Standard var(--text-and-icon-secondary) — the border, on a transparent box.
-
2
Selected var(--text-and-icon-primary) — the fill and the border together.var(--text-and-icon-inverse-primary) — the dot.
-
3
Disabled var(--text-and-icon-disabled) — the border. A token swap, never opacity.
-
4
Selected disabled var(--text-and-icon-disabled) — the fill and the border.var(--text-and-icon-inverse-primary) — the dot, still visible.
Anatomy
Three nested layers, mirroring the Figma component chain: the touch target wraps the box, the box carries the dot. All three are concentric, so one specimen shows the lot.
-
1
.radiobutton var(--sp-s48) — the safe zone. The touch target and the focusable element. Required.
-
2
.radiobutton__box var(--sp-s24) — the visible box, centred in the safe zone. Required.var(--sp-s12) radius, so it is fully round, with a var(--sp-s2) border drawn inside it.
-
3
.radiobutton__dot var(--sp-s12) at var(--sp-s6) radius — round, centred, required.Collapsed to scale(0) while unselected rather than removed. One geometry with two dot sizes, which is what lets it grow from the centre instead of appearing.
Layout
Every dimension comes from the SP scale, and every inset is centring rather than padding — each layer sits in the middle of the one above it.
-
General var(--sp-s48) — the touch target (WCAG 2.5.5).var(--sp-s24) — the box, at var(--sp-s12) radius, so it is a circle.var(--sp-s12) — the dot, at var(--sp-s6) radius.var(--sp-s2) — the border, drawn inside the box.var(--sp-s0) — padding, on every layer.
-
1
The box, centred 12 on each side comes out of centring a 24 box in a 48 target, not out of a padding value. Which is why var(--sp-s0) is the padding and the inset still holds.
-
2
The target var(--sp-s48) square — the focusable, clickable element. In a group the targets stack with no gap: 48 already leaves 12 of clear space around each box.
-
3
The dot, centred The ring of fill around it is var(--sp-s6) — again centring, a 12 dot inside a 24 box. The border is drawn inside the box, so the 24 outer size holds whatever the state.
Animation
One token carries the whole state change, in both directions. Click either option — one dot grows while the other collapses.
-
1
The box var(--transforming-state-default) — 200ms on slow-ease-out, the same token both ways.background-color and border-color. Both land on one token when selected, so the 24 outer size never shifts mid-transition.
-
2
The dot transform scale(0) to scale(1), with opacity alongside it, on the same token. Which is why it grows out of the centre rather than appearing at full size — the dot is always in the DOM, only collapsed.Catalogued in motion-rules.md §6.6 as Transforming/State/Default.
-
Reduced motion The token drops to 0ms and the change is instant. Zeroed at the token in motion.css, so the component carries no override of its own.
Usage
RadioButton answers a question where exactly one option can hold. The shape of the question decides the control, not how the options are worded: if one answer excludes the others it is Radio, if they are independent it is Checkbox, and if the change applies the moment it is made it is Switch.
-
1
Use for Exactly one option out of several, where choosing one clears the previous. The group is the unit a person answers — a lone RadioButton has nothing to exclude and no way back once set.
-
2
Not for Options that can hold at the same time — that is Checkbox. An immediate on/off that applies without confirmation — that is Switch.
Accessibility
-
1
The group is what makes them a set role="radiogroup" on the container, named by aria-labelledby, so the question is read before the first option.Without the wrapper a screen reader announces "1 of 1" for every option and the exclusivity is lost. TalkBack derives "2 of 4" from the container, which is why it is required rather than decorative.One Tab stop for the whole group — roving tabindex: 0 on the selected option, -1 on the rest. With nothing selected yet, the 0 sits on the first option.
-
2
The s48 target role="radio" and aria-checked, mirroring the visual state. Required, not optional: the DS markup is a <button> so the box can be styled, and a button announces as a button and exposes no value.type="button" as well. Without it a button inside a form defaults to submit, so every activation would submit — including a TalkBack or VoiceOver double tap, which fires a synthesized click rather than a key.Focus never lands on the s24 box — that would give a 24 x 24 hit area and fail WCAG 2.5.5. The minimum is 24, 44 is recommended for pointer input, and the DS uses 48.:focus-visible — var(--sp-s2) in var(--text-and-icon-primary) at radius var(--sp-s24), inset. Round, matching the control, and inset so it never collides with the neighbouring option.aria-checked and data-ds-state move together on both options — the incoming and the outgoing — so the accessible value and the analytics value cannot drift.
-
3
Disabled The two platforms differ, and the difference is deliberate. TalkBack appends "Disabled", skips the option in swipe navigation and ignores a double tap. VoiceOver appends "Dimmed" and keeps it in the rotor, so its state stays discoverable.The arrow keys skip it rather than focusing it and refusing.A whole group disabled is announced once, on the group. Not repeated by every option.
-
Keyboard ↓ → and ↑ ← move to the next or previous option and select it, wrapping at both ends. Moving the selection moves focus with it, so the focused and the selected option stay one element — that is what makes the group a single stop.Space selects the focused option. Enter does nothing — it stays free to submit the surrounding form.A real <button> synthesizes a click on Enter, so a bare click handler would select on it. The handler flags Enter on keydown and swallows the click it produces. A native input needs none of this — grouping by name gives it the arrows, the roving stop and the Enter behaviour for free.
-
Never colour alone The dot is the non-colour indicator that separates selected from unselected. Which matters because var(--text-and-icon-disabled) sits below the 3:1 floor on purpose — a disabled control is exempt from WCAG 1.4.11, so disabled is never the only carrier of information.