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

state: unselected
Default. A flat circle with a quiet border, no dot.
state: selected
Fills in solid gold and sets a flat white dot.
state: disabled
Dimmed to 50% with a not-allowed cursor; holds its value.
state: invalid
Rust frame on every radio in the group, set via RadioGroup's invalid prop — usually "required, nothing selected".
state: focus
Gold focus ring via :focus-visible on the underlying input. Shown statically here through the is-focused modifier; tab to a live group for the real ring.

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.

Review cadence
Bound value: monthly

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-px hairline in --border-hairline at rest, darkening to --fg-1 when 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-2 over --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-ring on :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 disabled is OR-ed with the group's. type, name, and checked are managed by the group.
ref: Ref<HTMLDivElement> | Ref<HTMLInputElement>
RadioGroup forwards to the radiogroup div; Radio forwards to the underlying input.

Usage

A labelled group — controlled, with an accessible nametsx
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>
Group states — disabled and invalidtsx
// 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.
  • /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.