Overview
<Radio /> is the single-of-many selection control. It ships with its parent —
<RadioGroup /> — because a lone radio button is rarely useful: the value of a radio is in
the comparison with its siblings. The two components are designed together, documented here together, and share the same
accessibility and theming contracts.
The visible affordance is a circular outer ring with an inner filled dot. Under the hood,
each <Radio> renders a real <input type="radio"> (visually hidden but keyboard-reachable
and form-participating) inside a <label>. Clicking the label toggles the box natively — no
synthetic handlers required. The inner dot is the control's ::before pseudo-element, kept
out of the DOM for the cleanest possible markup.
Radio reuses the three-slot recipe pattern Checkbox and Switch established: root, control,
label. Same hidden-input + custom-indicator trick, same useThemedClasses slot wiring, same
dev-mode warning hook.
Overview — shipping speed with three options
When to use which
<RadioGroup>+<Radio>— almost always. The group carries the selected value, setsaria-required/aria-invalidfor the whole set, and lets you writename="size"/color="success"once at the group level instead of repeating it on every option.- Standalone
<Radio>— rare, but supported as an escape hatch (testing, ad-hoc form rows, single-option opt-ins). The standalone Radio behaves like a binary toggle whose unchecked state is meaningful — closer in spirit to a checkbox than a true radio.
Anatomy
[ ring ⊙ | label / description column ][ ring ⊙ | label / description column ]- control — the outer ring + inner dot. Variant × color lives here.
aria-hidden. - label —
children. Click target via the wrapping<label htmlFor>. - description — optional secondary text below the label, wired via
aria-describedby.
Examples
Basic
Variants
Sizes
Colors
Horizontal
WithDescription
Disabled
Invalid
Standalone
Controlled
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | — | Form value the selection submits as. Required even outside a group — a radio without a `value` can't participate in form submission. Dev-warns if missing (`RADIO_NO_VALUE`). |
| checked | boolean | — | Controlled checked state. Only meaningful when the Radio is **not** inside a `<RadioGroup>`. |
| color | ResponsiveValue<RadioColor> | 'primary' | Semantic palette role driving the checked fill / ring / dot color. |
| defaultChecked | boolean | false | Uncontrolled initial checked state. Only meaningful outside a `<RadioGroup>`. |
| description | ReactNode | — | Optional secondary text rendered below the label, wired via `aria-describedby`. |
| invalid | boolean | — | Visual + a11y invalid state (sets `aria-invalid` + `data-invalid` on the indicator). |
| labelPosition | enum | 'right' | Logical label side. |
| onChange | ((event: ChangeEvent<HTMLInputElement, Element>) => void) | — | Native change handler. Preserved alongside `onCheckedChange`. |
| onCheckedChange | ((checked: boolean) => void) | — | Canonical handler — receives the new boolean directly. Use alongside or instead of `onChange` (which still fires with the native `ChangeEvent`). Inside a `<RadioGroup>`, prefer `<RadioGroup onValueChange>` — it gives you the **string** value of the selected option, which is almost always what you want. |
| size | ResponsiveValue<RadioSize> | 'md' | Ring + dot + label size. |
| sx | Sx | — | Theme-aware inline style object (resolves palette / spacing / radius tokens to CSS vars). |
| variant | ResponsiveValue<RadioVariant> | 'solid' | Stylistic family of the checked indicator. |
Accessibility
<RadioGroup>renders a<div role="radiogroup">witharia-required/aria-invalid/aria-disabledreflecting its props.- Each
<Radio>wraps a real<input type="radio">. Native radio-button keyboard semantics apply: arrow keys move selection between members of the samename, Tab moves into the group (focus landing on the currently-checked option, per W3C ARIA Authoring Practices), and Space selects the focused option. - The native input is visually hidden via
peer sr-onlyso focus, form submission, and screen-reader announcements all flow through it.aria-checkedis set automatically by the browser based on the input'scheckedstate. - Focus ring lands on the control (not just the hidden input) via the
peer-focus-visiblevariant. The ring color matches the activecolorfor every variant × color cell. invalidsetsaria-invalidon the input anddata-invalidon the control. The group also exposesaria-invalid="true"when itsinvalidprop is set so assistive tech announces the error context once per group rather than per option.descriptiontext gets an auto-generated id and is wired intoaria-describedby. If the consumer also supplies their ownaria-describedby, both ids are merged space-separated.- Two dev-mode warnings fire when DS contracts are broken:
RADIO_NO_VALUEwhen a<Radio>ships without a non-emptyvalue, andRADIO_NO_LABELwhen none ofchildren,aria-label, oraria-labelledbyis provided. - axe-core passes across the variant × color matrix and across disabled / invalid / description states.
Theming
Each of the three slots can be overridden independently:
<ThemeProvider
theme={defineTheme({
components: {
Radio: {
defaultProps: { variant: 'outline' },
styleOverrides: {
control: 'shadow-xs',
label: 'font-medium',
},
},
RadioGroup: {
defaultProps: { orientation: 'horizontal' },
styleOverrides: { root: 'gap-6' },
},
},
})}
>
{children}
</ThemeProvider><ThemeProvider
theme={defineTheme({
components: {
Radio: {
defaultProps: { variant: 'outline' },
styleOverrides: {
control: 'shadow-xs',
label: 'font-medium',
},
},
RadioGroup: {
defaultProps: { orientation: 'horizontal' },
styleOverrides: { root: 'gap-6' },
},
},
})}
>
{children}
</ThemeProvider>Instance-level overrides win via tailwind-merge:
<Radio value="x" className="font-bold" sx={{ radius: 'lg' }}>
Override me
</Radio><Radio value="x" className="font-bold" sx={{ radius: 'lg' }}>
Override me
</Radio>The recipe uses CSS-var-mapped utilities (e.g. rounded-full for the ring) so theme variants
like Katana flow through automatically — switching the app-level theme variant changes the
var values without touching the recipe.
RTL
labelPositionis logical —rightmeans end in LTR and start in RTL (mapped viaflex-row/flex-row-reverse).orientation="horizontal"flips reading order natively viaflex.- Arrow-key navigation flips Left/Right meaning in RTL per the ARIA spec; native handling for
<input type="radio">covers this without DS-level plumbing.
Group propagation
<RadioGroup> propagates the following props down to every child <Radio> as defaults —
each child can still override locally:
| Group prop | Per-Radio fallback |
|---|---|
name | child name wins if set, otherwise inherits |
variant | child variant wins if set, otherwise inherits |
size | child size wins if set, otherwise inherits |
color | child color wins if set, otherwise inherits |
disabled | OR'd — child is disabled if either the child or the group says so |
invalid | OR'd — child shows the invalid state if either source flags it |
Picking the group's selected value is fully controlled / uncontrolled via the engine's
useControllableState (the same hook Checkbox, Switch, and Accordion lean on). Use
value + onValueChange for the controlled mode, defaultValue for the uncontrolled
mode — never both.
Do / Don't
- Do label every radio — verb or noun phrases ("Standard shipping", "Pro plan") read
better than abstract codes. Provide
aria-labelonly when the visual surface deliberately has no text. - Do wrap radios in
<RadioGroup>whenever they're mutually exclusive — the group exists to setrole="radiogroup", propagatename, and centralize state. Skipping it forces every consumer to re-implement those. - Do prefer
<Radio>over<Checkbox>for mutually exclusive options. Even when there are only two choices, "ON / OFF" reads differently from "this OR that". - Don't disable a radio without telling the user why; pair
disabledwith a tooltip or helper text when feasible. - Don't put a
<RadioGroup>inside another<RadioGroup>. Nest with care: a single radio belongs to exactly one group, and the closestRadioGroupContextwins.