DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

Usage

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 pair required with 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

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"group / "full"Ring corner radius (§2 cascade)
indicator"dot" | "check"group / "dot"Selected glyph
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
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
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
rounded"none" | "sm" | "md" | "lg" | "full"unsetPropagated 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
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

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

PropTypeDefaultDescription
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)
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 are gated ON with motion-safe:, so reduced motion simply never starts them, and the press scale is reset with motion-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.
Radial ChartRating

On this page

PlaygroundInstallationUsageWhen to useExamplesDefaultSizesIntentsWith descriptionsValidationStatesSettings rowsHorizontalRich labelsIcon and trailing slotsCheck indicatorCardsCard badges and pricingCard anatomyForm integrationAPI ReferenceRadio propsRadioGroup propsRadioCard propsRadioCardGroup propsRadioSkeleton propsStatesSizesAccessibility