Components
Radio
The classic pick-one control. RadioGroup holds the single value; each Radio is one mutually exclusive option, usually saved on submit. Unselected is a plain circle with a quiet border; selected fills in solid gold with a white dot — friendly and unmistakable. Built on real <input type="radio"> inputs sharing the group's name, so the WAI-ARIA radio keyboard pattern — Tab lands on the group, arrows move and select — comes from the platform, not a re-implementation.
Radio is the bare control and must render inside a RadioGroup, which provides the shared name, the selected value, and the change handler over context. When the option list grows long or needs search, reach for <Selector>; when flipping acts immediately, that is a <Switch> — a radio picks one of several deferred options, a switch turns one thing on right now.
States
Unselected is a flat circle with a quiet border; selected fills in gold and sets a white dot; invalid swaps the border to the error red (group-wide, with aria-invalid on the radiogroup); disabled dims the whole control. Hover deepens the border; focus brings the gold ring. The dot always carries the state alongside the color, so it reads for color-blind users too (ADR-0017).
A group — pick one
RadioGroup is the field, not the option: it owns the one selected value and the change handler, and renders the radiogroup semantics. Click a label or use the arrow keys — moving the dot selects as it moves, wrapping at the ends, exactly the native radio behaviour. Tab treats the whole group as one stop.
In a form
There is no RadioGroupField yet — the form-bound compound is a named gap in the form layer (CheckboxField and SelectorField exist; the radio sibling is flagged). For now, bind value / onValueChange to your form state directly, point the group at your label with aria-labelledby, and wire the error text yourself via the invalid prop.
Accessibility
- Keyboard is the platform's. Real radio inputs sharing a name give the WAI-ARIA pattern natively: Tab lands on the selected (or first) radio, arrows move and select with wrap, Space selects when nothing is.
- Name the group. The radiogroup needs an accessible name — pass aria-label or point aria-labelledby at your visible legend. Each option needs a label too: wrap it in a label element so the text is clickable.
- State never rides colour alone. The dot is the selected mark (ADR-0017); invalid pairs the rust frame with aria-invalid on the group, so assistive tech hears what the colour shows.
- Reduced motion is respected. The dot's settle and the frame transitions collapse to instant state changes under prefers-reduced-motion.
Materiality
The spec values below document the proposed system's ink-and-engraving treatment. The official radio plays it differently: flat and opaque, a quiet border when empty, a solid gold fill with a white dot when selected. Color does the talking here — it usually does.
- frame
--space-4(16px) circle,--space-pxhairline in--border-hairlineat rest, darkening to--fg-1when selected.A flat circle on an opaque surface — nothing shows through. The circle-vs-square silhouette is still the pick-one-vs-pick-many signal.
- dot
--space-2(8px) disc — top-lit--fg-2over--fg-1, set on the glass-engraving highlight.No engraving, no metal: the official dot is a flat white disc on the gold fill. It still settles in quickly — playful, not bouncy.
- gold fill
- The signature.
The proposed system keeps a selected radio quiet on hue — that restraint lives over there. The official system goes the other way on purpose: selected means a solid gold fill with a white dot, the same warm, direct signal as the official checkbox. Gold stays an action / active-state color, never a container frame.
- focus ring
--focus-ringon :focus-visible; invalid groups swap to--focus-ring-error.Keyboard focus lands the same gold ring the rest of the system uses; pointer clicks don't.
Props
- RadioGroup · value: string | null
- Controlled selected value; null when nothing is selected yet. Required — there is no uncontrolled mode.
- RadioGroup · onValueChange: (value: string) => void
- Fired with the newly selected option's value. Required.
- RadioGroup · name?: string
- The shared input name that makes the browser treat the options as one group. Auto-generated per group when omitted; pass it only when the group must submit under a known field name.
- RadioGroup · disabled?, invalid?: boolean
- Group-wide flags: disabled dims and blocks every option; invalid sets the rust frames and aria-invalid on the radiogroup.
- Radio · value: string
- This option's value — it renders selected when it equals the group's value. A Radio outside a RadioGroup throws.
- Radio · id, onBlur, disabled, …
- Standard input attributes pass through to the real radio; per-option
disabledis OR-ed with the group's.type,name, andcheckedare managed by the group. - ref: Ref<HTMLDivElement> | Ref<HTMLInputElement>
- RadioGroup forwards to the radiogroup div; Radio forwards to the underlying input.
Usage
import { Radio, RadioGroup } from '@halo-compliance/ui'
const [cadence, setCadence] = useState<string | null>(null)
<strong id="cadence-legend">Review cadence</strong>
<RadioGroup
value={cadence}
onValueChange={setCadence}
aria-labelledby="cadence-legend"
>
<label><Radio value="weekly" /> Weekly</label>
<label><Radio value="monthly" /> Monthly</label>
<label><Radio value="quarterly" /> Quarterly</label>
</RadioGroup>// Disabled — every option dims and blocks.
<RadioGroup value="monthly" onValueChange={setCadence} disabled aria-label="Review cadence">
…
</RadioGroup>
// Invalid — rust frames + aria-invalid on the radiogroup
// (usually "required, nothing selected yet").
<RadioGroup value={null} onValueChange={setCadence} invalid aria-label="Review cadence">
…
</RadioGroup>When to use
- One choice among a few visible options — two to roughly five, where seeing every option at once is the point: a cadence, a mode, a tier.
- A deferred pick — the selection applies on submit or save, not the instant the dot moves.
- A default worth showing — radios make the preselected option visible alongside its alternatives, where a closed select hides them.
Antipatterns
- Independent on/off choices. If more than one may be selected, those are Checkboxes — radios are strictly one-of.
- An instant-apply toggle pair. "On / Off" as two radios that act immediately is a Switch wearing a costume — switches mean "this happens right now".
- A long option list. Past roughly five to seven options, a column of radios becomes a wall — reach for Selector.
- A nameless group or option. Always give the group aria-label or aria-labelledby, and wrap each Radio in a label so the text is clickable.
Related
- /checkbox — the pick-many sibling; the same gold-fill register, square instead of round.
- /selector — pick-one at scale, or searchable, when a visible column of radios is too many.
- /switch — the live / immediate control: flip it and it happens right away.
- /tokens —
--space-4,--radius-pill, and the focus-ring / duration tokens.