Skip to main content

Radio

betav0.1.0

Single-choice control built on @radix-ui/react-radio-group. RadioGroup owns the value and each Radio derives its selected state from it — there is deliberately no per-item `checked` prop, which is exactly what the Figma component note prescribes. Radix supplies role="radiogroup", roving tabindex and arrow-key navigation. Each item is a 16px circle with an optional integrated label and description, so no separate FormControlLabel wrapper is needed. The circle is transparent at rest by design and inherits its container's surface; --bg-input-default appears only on hover and focus. There is deliberately no error prop: validation state is carried by the surrounding form field — see Radio.rfc.md.

Variants

Selection is owned by the group

With descriptions

Anyone with the link can view this.
Only you and the people you invite can view this.

Per-item disabled — colour substitution, never opacity

Not available on your current plan.

Whole group disabled

On an elevated surface — the circle is transparent at rest and picks up the container

Examples

<RadioGroup aria-label="Visibility" value={value} onValueChange={setValue}>
  <Radio value="public" label="Public" />
  <Radio value="private" label="Private" />
</RadioGroup>
<RadioGroup aria-labelledby="visibility-title" defaultValue="public">…</RadioGroup>
<Radio value="public" label="Public" description="Anyone with the link can view this." />
<Radio value="enterprise" label="Enterprise" disabled />
<RadioGroup aria-label="Plan" name="plan" defaultValue="standard">…</RadioGroup>
<Radio ref={radioRef} value="public" label="Public" />

Props

PropType
RadioGroup valuestring — Controlled selected value
RadioGroup defaultValuestring — Default selected value (uncontrolled)
RadioGroup onValueChange(value: string) => void — Fires when the selection changes. Replaces MUI's onChange(e) => e.target.value
RadioGroup namestring — Name submitted with the owning form
RadioGroup disabledboolean — Disables every item in the group
RadioGroup aria-label / aria-labelledbystring — REQUIRED. The group has no built-in title in this version and must be named
RadioGroup orientation"vertical" | "horizontal" — Layout axis, read from node 7416:23634 (default: "vertical"). Horizontal lays the items on a row with the same 16px gap. It is a LAYOUT prop and is not forwarded to Radix, whose single orientation prop would also restrict the arrow keys to the axis-native pair; aria-orientation is set by the component itself, so it is exact AND all four arrows keep traversing the group in both axes
RadioGroup classNamestring — Additional classes for the group container
Radio valuestring — REQUIRED. Selected when it equals the group's value
Radio labelReactNode — Integrated label rendered next to the circle, natively associated with it
Radio descriptionReactNode — Secondary text below the label, indented to align under it and wired via aria-describedby
Radio disabledboolean — Disables this item only (colour substitution, never opacity)
Radio wrapperClassNamestring — Additional classes for the wrapper holding label and description (only applies when `label` or `description` is set)
Radio idstring — Overrides the auto-generated id used for label association
Radio classNamestring — Additional classes for the circle itself

Accessibility

2.1 AAWCAG pass

Radix Root renders role="radiogroup" and each Item a native <button> with role="radio" and aria-checked. Tab enters the group once, at the selected item or the first one; ArrowUp/ArrowDown and ArrowLeft/ArrowRight move and select within the group, and only one item is ever in the tab order. forwardRef is exposed on both RadioGroup and Radio (repo convention for form-field components). Each label uses native <label htmlFor>/<button id> association and each description is wired via aria-describedby. THE GROUP MUST BE NAMED. This version ships no built-in group title, so RadioGroup requires aria-label or aria-labelledby: a radiogroup with no accessible name fails SC 1.3.1 and SC 4.1.2, and the group label is what tells a screen-reader user what the mutually exclusive choice is about. TWO DELIBERATE DEVIATIONS FROM FIGMA, both declared in Radio.figma.json with their rationale. First, focus uses the repo's standard ring rather than Figma's 1px-offset/2px-ring geometry: one focus indicator across the library, defined and tested once. The ring colour is identical and the indicator is at least as thick, so SC 2.4.11 and SC 2.4.13 are preserved or improved. Second, disabled is expressed by substituting colours rather than by an opacity tint — which is what Figma binds. THE CIRCLE HAS NO BACKGROUND AT REST. An arbitrated design decision, not an omission: the circle is transparent and inherits its container's surface, so the 1px border is what carries perception when unselected and unfocused. This was confirmed independently — the Radio Group node renders its unselected circles as real layers with no fill. Contrast pairs are declared against the standard reference surface only. Selection is never conveyed by colour alone: the 8px dot and aria-checked both carry it (WCAG 1.4.1). Disabled state is exempt from contrast (WCAG 1.4.3 Note 1, inactive UI components): --bg-input-disabled, --text-default-disabled, --text-input-disabled and --icon-disabled are reached exclusively through disabled: variants. The disabled label pair is declared anyway and clears its threshold at 9.38:1 dark / 7.20:1 light. MEASURED RATIOS, computed by pnpm check:contrast against the standard reference surface, dark then light. Everything that carries the component or its state is above threshold: circle border at rest 3.14:1 / 3.72:1 (needs 3:1); selected dot 10.30:1 / 11.22:1; focus ring 10.30:1 / 11.22:1; label text 17.38:1 / 15.72:1; description text 8.99:1 / 7.89:1. Two SUPPLEMENTAL treatments are deliberately not declared as pairs: the hover and focus circle fill --bg-input-default at 1.22:1 / 1.13:1, and the hover halo at 1.17:1 / 1.22:1. Neither is required to identify the component or its selection, and neither could clear 3:1 without ceasing to read as a hover. The circle is identified by its border, which is above threshold in both modes, and the selection is carried by the dot and by aria-checked, both also above threshold. Understanding SC 1.4.11's 'Hover states' guidance is explicit that author-supplied hover treatments can be considered supplemental. Their Rule 5 coverage is signed off in scripts/contrast-coverage-allowlist.json (dated, with a removal condition) — the coverage mechanism, not a WCAG derogation: there is no wcag-allowlist entry for this component. wcagStatus is pass because the component is conformant, not by exemption. The underlying tooling gap — ContrastPair cannot mark a pair supplemental, so a supplemental treatment can only be omitted or counted against the verdict — is tracked HIGH PRIORITY in process/AI-BACKLOG.md. Horizontal orientation is NOT implemented in this version. Design has drawn the variant but it is not yet readable through the Figma API, and this wave's rule is that no visual value is invented — see Radio.rfc.md section 3.6.