Radio
A form control that lets users select exactly one option from a group. Wrapping-label rows, logical labelPlacement, icon/trailing slots, dot or check indicator, RadioCard with badges and custom content, and native form validation.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Radio, RadioGroup } from "@tessinaui/ui";{/* Uncontrolled group */}
<RadioGroup defaultValue="email" label="Contact method">
<Radio value="email" label="Email" />
<Radio value="sms" label="SMS" />
<Radio value="phone" label="Phone" />
</RadioGroup>
{/* Controlled group */}
<RadioGroup value={method} onValueChange={setMethod} label="Contact method">
<Radio value="email" label="Email" />
<Radio value="sms" label="SMS" />
</RadioGroup>
{/* With a group error */}
<RadioGroup required label="Plan" errorMessage="Please select an option.">
<Radio value="a" label="Option A" />
<Radio value="b" label="Option B" />
</RadioGroup>When to use
- 2–5 visible options that benefit from side-by-side comparison. Past ~5, reach for Select — Material, Fluent and the macOS HIG all converge on the same ceiling.
- Vertical by default. Horizontal groups save space in dense layouts but scan worse and break under localization; avoid them past ~4 short options.
- Not a view switcher. Switching between views or modes in place is SegmentedControl's job; a radio group is a form answer. Filter chips that select one value are Chips.
- Not multi-select. Zero-or-more selection is a Checkbox group. Radio and checkbox are not interchangeable.
- Defaults: pick the safest, lowest-risk option as
defaultValue— or leave the group unselected and pairrequiredwith a "None of the above" option when the choice must be deliberate (the GOV.UK posture). Never preselect a high-risk option (payments, privacy, deletion). - A disabled option with a visible reason reads better than a missing one —
put the reason in
description.
Examples
Default
A radio group with three options and a group label.
Sizes
Five indicator sizes — xs, sm, md, lg, and xl. Tap targets stay
≥44px on touch viewports at every size.
Intents
Semantic colors for the ring and glyph — none, error, warning,
success, and info.
With descriptions
Each option pairs a label with helper text below it.
Validation
A required group error, and a warning message — an explicit intent keeps
its color instead of being repainted red.
States
Disabled (with an in-description reason), loading, and a read-only group — focusable and announced, but not operable.
Settings rows
labelPlacement="start" + fullWidth is the mobile settings-row: text
leading, control trailing, the whole row one tap target. Dividers are
composition (divide-y), not a prop.
Horizontal
orientation="horizontal" wraps options in a row — with a group description
and an optional marker.
Rich labels
Labels take ReactNode — links inside a label keep working without changing
the selection. Pass aria-label when rich content would read poorly.
Icon and trailing slots
The checkout pattern: a leading mark via icon, the fee via trailing.
Both slots are decorative to assistive tech.
Check indicator
indicator="check" swaps the selected dot for a check mark, propagated from
the group.
Cards
Full-surface RadioCard options arranged in a card group.
Card badges and pricing
A badge pinned over the card's top edge joins the accessible description;
the price lives in trailing.
Card anatomy
Indicator leading the content, no indicator at all (border + tint carry the
selection), and arbitrary children below the description.
Form integration
name submits through a hidden input and required marks the group invalid
until a selection exists. noValidate on the form suppresses the browser's own
validation bubble so the message is the group's errorMessage — house styling,
a polite live region, in the flow of the form rather than a tooltip that
disappears on the next click — and focus moves to the group on failure.
API Reference
Radio props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Required. Value submitted with the form and matched against the group value |
size | "xs" | "sm" | "md" | "lg" | "xl" | group / "md" | Ring size (14/16/20/24/28px) |
intent | "none" | "error" | "warning" | "success" | "info" | group / "none" | Semantic color for ring and glyph |
rounded | "none" | "sm" | "md" | "lg" | "full" | group / "full" | Ring corner radius (§2 cascade) |
indicator | "dot" | "check" | group / "dot" | Selected glyph |
label | ReactNode | — | Visible label — the accessible name |
description | ReactNode | — | Helper text below the label; reaches AT via aria-describedby |
errorMessage | ReactNode | — | Validation message (replaces description, polite live region). Colored by intent; defaults to error styling when intent is none |
labelPlacement | "start" | "end" | "end" | Logical label side — start is the trailing-control settings row |
labelPosition | "left" | "right" | — | Deprecated — use labelPlacement. Removed next minor |
fullWidth | boolean | false | Row stretches; text column grows |
visuallyHiddenLabel | boolean | false | Keep the name for AT, hide the text |
required | boolean | false | Native required + asterisk |
optional | boolean | false | Muted "(optional)" marker; exclusive with required |
isLoading | boolean | false | Spinner replaces the glyph, aria-busy, interaction blocked |
icon | ReactNode | — | Leading media slot (decorative) |
trailing | ReactNode | — | Trailing meta slot (decorative) |
disabled | boolean | group | Prevents interaction, out of tab order |
readOnly | boolean | false | Focusable and announced, not operable |
dir | "ltr" | "rtl" | — | Text direction for the wrapper |
className | string | — | Extra classes on the wrapper <div> |
All other native and aria-* attributes are forwarded to the control (Base
UI <span role="radio"> + hidden input) — the documented form-control
exception shape (§7).
RadioGroup props
| Prop | Type | Default | Description |
|---|---|---|---|
value / defaultValue | string | — | Controlled / uncontrolled selected value |
onValueChange | (value, eventDetails) => void | — | Change handler (Base UI event details included) |
name | string | — | Form submission name (hidden input) |
required | boolean | false | Asterisk + native group validation |
optional | boolean | false | Muted "(optional)" marker on the legend |
disabled | boolean | false | Disables all radios |
readOnly | boolean | false | Selection cannot change; still focusable |
orientation | "vertical" | "horizontal" | "vertical" | Stack direction |
size / intent / indicator | — | "md" / "none" / "dot" | Propagated to children; explicit child props win |
rounded | "none" | "sm" | "md" | "lg" | "full" | unset | Propagated when you set it. Left unset each child keeps its own default — a ring is a circle, a card shell is xl — because the two run different scales |
label | ReactNode | — | Legend — the radiogroup's accessible name |
description | ReactNode | — | Helper below the legend, joined via aria-describedby |
errorMessage | ReactNode | — | Group error (polite live region) |
dir | "ltr" | "rtl" | — | Direction for the fieldset |
RadioCard props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Required. Value in the group |
title | ReactNode | — | Required. The card's accessible name |
description | ReactNode | — | Below the title; accessible description |
icon | ReactNode | — | Leading icon (decorative) |
badge | ReactNode | — | Pinned over the top-end edge; joins the accessible description |
trailing | ReactNode | — | Price/meta beside the indicator (decorative) |
children | ReactNode | — | Extra content below the description (feature lists) |
indicatorPosition | "end" | "start" | "end" | Corner indicator, or leading the content |
showIndicator | boolean | true | false leaves selection to border + tint |
indicator | "dot" | "check" | group / "dot" | Selected glyph |
size / intent / rounded | — | group | rounded runs the 6-step card SHELL scale (xl default) |
disabled / readOnly / dir | — | — | As on Radio |
rounded shapes the card, never the radio inside it: the indicator ring and
its dot stay circular at every card radius, because a circle is what makes a
radio read as a radio (QA flow §7.4 identity geometry — the same rule that
keeps status dots, the switch thumb and the slider thumb round). This is where
RadioCard and CheckboxCard diverge on purpose — a square box is a
checkbox's identity, so its indicator does follow the card.
RadioCardGroup props
Same value/name/validation/propagation surface as RadioGroup, plus
columns (1 \| 2 \| 3 \| 4, default 2) and indicator/rounded
propagation. Renders the same named-fieldset semantics.
RadioSkeleton props
| Prop | Type | Default | Description |
|---|---|---|---|
size / rounded | — | "md" / "full" | Mirrors the real ring box (from the component's own cva, §2b) |
labelPlacement | "start" | "end" | "end" | Mirrors the row layout (labelPosition deprecated alias) |
fullWidth | boolean | false | Mirrors the settings row |
showLabel / showDescription / showError | boolean | true / false / false | Which bars render (error replaces description) |
labelChars / descriptionChars | number | size-scaled | Bar length in characters |
States
| State | Visual |
|---|---|
| Unselected | Input-well fill + --outline-border boundary (≥3:1) |
| Selected | Intent fill + dot/check in the fill's own on-color ink |
| Hover | Fill/border steps to the -hover token |
| Pressed | -pressed token; press scale (silenced under reduced motion) |
| Focus | 2px ring on --ring-offset-surface |
| Disabled | Muted ring/fill/text; out of tab order |
| Read-only / loading | Full-opacity, focusable, not operable; loading adds aria-busy + spinner |
Sizes
| Size | Ring | Dot | Check |
|---|---|---|---|
xs | 14×14 px | 6 px | 9 px |
sm | 16×16 px | 8 px | 11 px |
md | 20×20 px | 10 px | 13 px |
lg | 24×24 px | 12 px | 16 px |
xl | 28×28 px | 14 px | 18 px |
Accessibility
Pattern: WAI-ARIA Radio Group, via Base UI's roving-tabindex implementation.
| Key | Behaviour |
|---|---|
Tab / Shift+Tab | Into the group — onto the checked radio, or the first when none is checked; one tab stop per group |
Space | Selects the focused radio |
↓ / → | Focus + select the next radio, wrapping from last to first |
↑ / ← | Focus + select the previous radio, wrapping from first to last |
Enter | Deliberately does nothing (APG; Base UI's span-button default is suppressed) |
- The group renders as one
<fieldset role="radiogroup">named by its<legend>(direct child) viaaria-labelledby— no nested groups. - The wrapping
<label>row makes the whole row the tap target; the control is named by the label text span alone, so the required asterisk, error text and slots never pollute the accessible name. descriptionanderrorMessagereach the control viaaria-describedby(resolvable ids, asserted in tests); errors are polite live regions and setaria-invalid.- Touch targets: 44px overlays + ≥44px row pitch on touch viewports; the 24px WCAG 2.5.8 minimum at desktop density, where 44px overlays would overlap stacked rows and steal neighbour clicks.
forced-colors: states map toButtonText/SelectedItem/GrayText.- Reduced motion: the glyph transitions are gated ON with
motion-safe:, so reduced motion simply never starts them, and the press scale is reset withmotion-reduce:active:scale-100. - AT matrix: VoiceOver + Safari verified locally; NVDA and JAWS untested (no Windows on the bench) — tracked for the next release pass.