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.
pnpm add @tessinaui/uiWhen to use
- One answer from a short list. A handful of visible options that benefit from side-by-side comparison. Past a handful, reach for Select: the group costs more vertical space than the comparison is worth.
- A choice that must be made. A radio cannot be unticked, so use it when an answer is required. Where the person may want none, include a "None of the above" option.
- Vertical by default. Horizontal groups save space in dense layouts but scan worse and break under localization; keep them to a few short options.
- A safe default, or none. 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, so a skipped question is never mistaken for an answered one. Never preselect a high-risk option (payments, privacy, deletion). - Options in a logical order, each with a visible reason when unavailable:
a disabled option with its reason in
descriptionreads better than a missing one.
When not to use
- For multi-select. Zero-or-more selection is a Checkbox group. Radio and checkbox are not interchangeable.
- For a long list. Use Select once the options stop fitting comfortably on screen.
- To switch a view or mode in place. That is SegmentedControl's job; a radio group is a form answer. Filter chips that select one value are Chips.
- For an on/off setting. Use Switch.
- When the answer may be nothing. Add a "None" option, or use a Checkbox.
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>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. The icon slot is one label line
tall, so an icon up to that height (16, 20, 20, 24 and 28px from xs to xl)
centres on the label's first line; a taller one grows the row and aligns with
the top of the label.
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.
On a coloured surface
Wrap a region in <Surface> and every radio inside inherits tone="on-color". The ring becomes the plate's well behind an ink boundary, the selected ring turns into the inverse chip, an intent keeps its own fill, and the label, description and error line take the plate's inks. A RadioCard stays a page panel on the plate.
RTL
dir="rtl" on the group mirrors every row: the ring moves to the right edge, text aligns right, and a labelPlacement="start" settings row puts the ring on the left. labelPlacement is logical, so it never needs flipping by hand. Leave dir unset and the group follows the page's direction.
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" | — | Deprecated, no effect. The ring and its dot are a circle at every value, none included: a rounded square reads as a checkbox. So a FormRoot's corner, which cascades to every control, never squares a radio |
indicator | "dot" | "check" | group / "dot" | Selected glyph |
tone | "default" | "on-color" | inherited from <Surface> | Colour recipe for a coloured or dark surface; inside a group the group's tone wins |
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 |
tapEdge | "none" | "inline-start" | "inline-end" | "none" | The edge the ring sits flush against; its 44px touch target grows inward instead of adding page scroll |
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 |
strings | Partial<RadioStrings> | English, Arabic under dir="rtl" | Override the optional marker ("(optional)"). Without a dir of its own, a radio in a group takes the group's |
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 |
tone | "default" | "on-color" | inherited from <Surface> | Colour recipe for the legend and helper text, propagated to children |
rounded | "none" | "sm" | "md" | "lg" | "full" | unset | Shapes the RadioCard shells inside the group when you set it; left unset, a card shell keeps its lg default. A radio's ring is a circle at every value |
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 |
strings | Partial<RadioStrings> | English, Arabic under dir="rtl" | Override the legend's optional marker and the one on child radios without a dir of their own. RadioCardGroup takes the same prop |
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 Card's corner scale (lg default, 16px), so a card shell matches a Card at the same value |
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. Its tone colours the
legend and helper text on a coloured surface; each card stays a page panel.
RadioSkeleton props
| Prop | Type | Default | Description |
|---|---|---|---|
size | — | "md" | Mirrors the real ring box (from the component's own cva, §2b); the ring placeholder is a circle, like the ring |
rounded | — | "full" | Corner radius of the text bars |
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 and the press scale are gated ON with
motion-safe:, so reduced motion simply never starts them. - Contrast (
audit:contrast, both themes): a checkedintent="warning"radio's raw-amber fill reads 2.2:1 against the light page, under the 3:1 WCAG 1.4.11 asks. That is the intent palette's own ceiling, accepted design-system-wide (§9, TES-272), not a Radio tuning miss, and the state never rests on the amber: theon-warningdot (or check) marks checked and is gated at 9.7:1 against the fill, and the unchecked warning boundary steps towarning-tinted-foreground(7.1:1 in light). Every other checked fill and unchecked boundary clears 3:1. - AT matrix: VoiceOver + Safari passed 2026-09-14 — "Contact method, radio group", then "Email, selected, radio button, 1 of 3"; Down arrow selects and reads "SMS, selected, radio button, 2 of 3", then "Phone … 3 of 3". NVDA and JAWS untested (no Windows on the bench).