DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
CheckboxRadioSwitchTextareaNumberFieldOTP InputSliderCircularSliderRatingSelectPickerComboboxSearchSearchSuggestTokenizerSegmentedControl
AI agentsContributing
  1. Components
  2. Radio

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/ui

When 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 pair required with 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 description reads 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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { Radio, RadioGroup } from "@tessinaui/ui";
example.tsx
{/* 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

PropTypeDefaultDescription
valuestring—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
labelReactNode—Visible label — the accessible name
descriptionReactNode—Helper text below the label; reaches AT via aria-describedby
errorMessageReactNode—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
fullWidthbooleanfalseRow 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
visuallyHiddenLabelbooleanfalseKeep the name for AT, hide the text
requiredbooleanfalseNative required + asterisk
optionalbooleanfalseMuted "(optional)" marker; exclusive with required
isLoadingbooleanfalseSpinner replaces the glyph, aria-busy, interaction blocked
iconReactNode—Leading media slot (decorative)
trailingReactNode—Trailing meta slot (decorative)
disabledbooleangroupPrevents interaction, out of tab order
readOnlybooleanfalseFocusable and announced, not operable
dir"ltr" | "rtl"—Text direction for the wrapper
stringsPartial<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
classNamestring—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

PropTypeDefaultDescription
value / defaultValuestring—Controlled / uncontrolled selected value
onValueChange(value, eventDetails) => void—Change handler (Base UI event details included)
namestring—Form submission name (hidden input)
requiredbooleanfalseAsterisk + native group validation
optionalbooleanfalseMuted "(optional)" marker on the legend
disabledbooleanfalseDisables all radios
readOnlybooleanfalseSelection 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"unsetShapes 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
labelReactNode—Legend — the radiogroup's accessible name
descriptionReactNode—Helper below the legend, joined via aria-describedby
errorMessageReactNode—Group error (polite live region)
dir"ltr" | "rtl"—Direction for the fieldset
stringsPartial<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

PropTypeDefaultDescription
valuestring—Required. Value in the group
titleReactNode—Required. The card's accessible name
descriptionReactNode—Below the title; accessible description
iconReactNode—Leading icon (decorative)
badgeReactNode—Pinned over the top-end edge; joins the accessible description
trailingReactNode—Price/meta beside the indicator (decorative)
childrenReactNode—Extra content below the description (feature lists)
indicatorPosition"end" | "start""end"Corner indicator, or leading the content
showIndicatorbooleantruefalse leaves selection to border + tint
indicator"dot" | "check"group / "dot"Selected glyph
size / intent / rounded—grouprounded 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

PropTypeDefaultDescription
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)
fullWidthbooleanfalseMirrors the settings row
showLabel / showDescription / showErrorbooleantrue / false / falseWhich bars render (error replaces description)
labelChars / descriptionCharsnumbersize-scaledBar length in characters

States

StateVisual
UnselectedInput-well fill + --outline-border boundary (≥3:1)
SelectedIntent fill + dot/check in the fill's own on-color ink
HoverFill/border steps to the -hover token
Pressed-pressed token; press scale (silenced under reduced motion)
Focus2px ring on --ring-offset-surface
DisabledMuted ring/fill/text; out of tab order
Read-only / loadingFull-opacity, focusable, not operable; loading adds aria-busy + spinner

Sizes

SizeRingDotCheck
xs14×14 px6 px9 px
sm16×16 px8 px11 px
md20×20 px10 px13 px
lg24×24 px12 px16 px
xl28×28 px14 px18 px

Accessibility

Pattern: WAI-ARIA Radio Group, via Base UI's roving-tabindex implementation.

KeyBehaviour
Tab / Shift+TabInto the group — onto the checked radio, or the first when none is checked; one tab stop per group
SpaceSelects the focused radio
↓ / →Focus + select the next radio, wrapping from last to first
↑ / ←Focus + select the previous radio, wrapping from first to last
EnterDeliberately 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) via aria-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.
  • description and errorMessage reach the control via aria-describedby (resolvable ids, asserted in tests); errors are polite live regions and set aria-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 to ButtonText / 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 checked intent="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: the on-warning dot (or check) marks checked and is gated at 9.7:1 against the fill, and the unchecked warning boundary steps to warning-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).
CheckboxSwitch

On this page

When to useWhen not to useInstallationUsageExamplesDefaultSizesIntentsWith descriptionsValidationStatesSettings rowsHorizontalRich labelsIcon and trailing slotsCheck indicatorCardsCard badges and pricingCard anatomyForm integrationOn a coloured surfaceRTLAPI ReferenceRadio propsRadioGroup propsRadioCard propsRadioCardGroup propsRadioSkeleton propsStatesSizesAccessibility