Radio
A single-select control. RadioGroup is the whole component — you pass it an
options array and it renders one radio per entry, owning selection, keyboard
navigation, and the form value. There’s no separate Radio component: options
are data, not markup.
Playground
Options, label, and hint
<RadioGroup label="How should we reach you?" hint="We'll only use this for account alerts." defaultValue="email" options={[ { value: 'email', label: 'Email', hint: 'A confirmation link goes to your inbox.' }, { value: 'sms', label: 'SMS', hint: 'Standard message rates apply.' }, { value: 'push', label: 'Push notification' }, ]}/>Each entry needs a unique value (its identity and submitted value) and a
label, plus an optional hint — secondary copy rendered under that option,
styled like Input’s hint.
The group’s own label and hint form a header above the options: the
label first, the hint (an instruction for the whole set) directly under it.
Each option can also set className, style, and data-*.
RadioGroup forwards them to that option’s <a-radio> so you can add
per-option presentation and application metadata.
Tone
{/* Usually one tone for the whole group: */}<RadioGroup tone="info" defaultValue="a" options={[ { value: 'a', label: 'Option A' }, { value: 'b', label: 'Option B' }, ]}/>neutral (default), brand, info, success, warning,
critical. tone colors the mark in every state — the selected-ring fill + dot
and (for a tinted tone) the unselected ring border (a light shade of the fill that
darkens on hover and active; neutral keeps a grey ring). The label and hint stay
neutral — color them in plain CSS with the --text-N-{tone} tokens. Set tone on
the RadioGroup to color every option, or on a single option to override just that
one. Any literal CSS color works too, for a one-off custom tone. Selected-only tone
<RadioGroup tone="brand" toneScope="selected" defaultValue="a" options={[ { value: 'a', label: 'Option A' }, { value: 'b', label: 'Option B' }, ]}/>toneScope="selected" applies the group’s tone to the selected option only —
every unselected ring stays neutral grey. Use it when a resting tinted border would
read as a validation error. Set the scope on the group, or on a single option to
override the group.
To color the label and hint, there’s no prop — add a color rule on the
option’s a-radio-label / a-radio-hint with a --text-N-{tone} value.
Size
{/* Usually one size for the whole group: */}<RadioGroup size="small" defaultValue="a" options={[ { value: 'a', label: 'Option A' }, { value: 'b', label: 'Option B' }, ]}/>small (14px ring / 13px label), medium (16px / 15px, the default), and
large (18px / 17px). Set it on the RadioGroup, or on a single option to
override one. Orientation and layout
<RadioGroup orientation="horizontal" defaultValue="yes" options={[ { value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }, { value: 'maybe', label: 'Maybe' }, ]}/>vertical (the default) stacks the options; horizontal lays them in a row.
Arrow keys move the selection either way.
For anything beyond that, the options live in a plain light-DOM <a-radio-list>,
so you can restyle their arrangement with ordinary CSS — no shadow parts:
/* e.g. a two-column grid */a-radio-group a-radio-list { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 16px; }Disabled
{/* `disabled` on one option, or on the group to disable the whole set */}<RadioGroup defaultValue="checked" options={[ { value: 'checked', label: 'Checked' }, { value: 'unchecked', label: 'Unchecked', disabled: true }, ]}/>disabled on a single option to disable just that one, or on the
RadioGroup to disable the whole set. Disabled options are skipped by keyboard
navigation and dropped from the tab order. Validation
<RadioGroup label="Pick a plan" hint="Please choose a plan to continue." status="critical" options={[ { value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }, ]}/>status recolors the group hint for feedback — the same tone set as
Input: critical, warning, success, info, brand
(omit for neutral). It styles the message only; drive it from your own validation. Keyboard and accessibility
The group is one Tab stop: Tab moves into the selected option (or the first
enabled one), the arrow keys (↑/↓/←/→) move the selection between enabled
options (wrapping at the ends), and Space / Enter select the focused option.
Follows the WAI-ARIA radio-group pattern.
Under the hood the split is deliberate. The <a-radio-group> web component
never mutates the DOM — it coordinates entirely off-DOM: selection is set as a
property on each <a-radio> (which reflects it into :state(selected) +
aria-checked via its own ElementInternals), the form value goes through
ElementInternals, and focus is tracked with aria-activedescendant. Each
option’s light-DOM label and hint also become its accessible name and description
through direct element references, without generated IDs. The
roving tabindex — which option Tab lands on — is the one piece rendered
declaratively, by the RadioGroup wrapper, from the current value. That’s why
selection is owned by the element but the wrapper holds the value: it needs it to
place the tab stop. (Hand-assembling raw <a-radio-group> / <a-radio> works
too; make the group the tab stop with tabindex="0" and it falls back to
aria-activedescendant for keyboard nav.)
Forms
<a-radio-group> is a form-associated custom element. Give it a name and the
selected option’s value is submitted with the form like a native radio group;
form reset restores the defaultValue selection.
<form> <RadioGroup name="contact" defaultValue="email" options={[ { value: 'email', label: 'Email' }, { value: 'sms', label: 'SMS' }, ]} /></form>// FormData → contact=emailControlled vs. uncontrolled
Uncontrolled — pass defaultValue and let the group own selection:
<RadioGroup name="plan" defaultValue="pro" options={[ { value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }, { value: 'team', label: 'Team' },]} />Controlled — pass value + onStateChange and own it yourself. The group
fires before applying, so you accept by updating value and reject by doing
nothing (or event.preventDefault() to veto in uncontrolled mode):
const [plan, setPlan] = useState('pro')
<RadioGroup name="plan" value={plan} onStateChange={(e, { next }) => setPlan(next)} options={[ { value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }, { value: 'team', label: 'Team' }, ]}/>onStateChange’s detail carries a reason: 'user' for a pick (the only
cancelable one — preventDefault() vetoes it), and 'reset' / 'restore'
when a <form> reset or a bfcache/autofill restore moves the selection. Filter on
reason if you only want user picks; next is null when nothing is selected.
Events
Three callbacks, in firing order:
| Callback | When | Cancelable | Payload |
|---|---|---|---|
onStateChange(e, { next, prev, reason }) | before a pick applies | ✅ (user picks only) | next / prev value + reason |
onChange(e) | after selection applies | — | native change event |
onValueChange(e, attrs) | after selection applies | — | { value, name } |
Reach for onStateChange to intercept/veto or to filter on reason (reset vs
restore vs user); for the everyday “react to the new value”, use onValueChange.
onChange is the plain native hook. onFocus / onBlur report focus entering
and leaving the group (wired to focusin / focusout, since focus lands on an
individual option).
The group has onStateChange because its selection is a discrete state (one
of a fixed set) the element self-applies — so a transition can be vetoed before it
lands, and it carries reason. Value-based composites without a self-applying
element — Input, and the composed Select — skip it and expose only
onValueChange; the onValueChange shape is the same across all of them.
<RadioGroup onValueChange={(_e, { value }) => save(value)} options={…} /> RadioGroup props
| Prop | Type | Default | Description |
|---|---|---|---|
| options | RadioOption[] | — | The options. The wrapper renders one <a-radio> per entry and computes its
selected / roving tabindex / role declaratively. |
| value? | string | — | Controlled selected value. The group follows this prop and reports a
requested change through onStateChange. Leave it undefined for
uncontrolled use. |
| defaultValue? | string | — | Initial selected value for uncontrolled use. |
| onStateChange? | (event, detail) => void | — | Fired whenever selection changes — event-first. detail is
{ next, prev, reason }: next/prev are values (null = nothing selected);
reason is 'user' | 'reset' | 'restore'. A 'user' pick fires before
applying and is cancelable — event.preventDefault() vetoes it
(uncontrolled), or in controlled mode answer by updating value (reject by
doing nothing). 'reset' (form reset) and 'restore' (bfcache / autofill) are
not cancelable — filter on reason if you only track user picks. |
| onChange? | (event) => void | — | Fired after the selection changes — a native change event (the post-apply
counterpart to onStateChange). Not cancelable; for a controlled group it
fires once you've updated value. |
| onValueChange? | (event, attrs) => void | — | Like onChange, but with a { value, name } snapshot as the second argument,
matching Input. |
| onFocus? | (event) => void | — | Fired when focus enters the group (any option) — wired to focusin, since
focus lands on an individual option, not the group element itself. |
| onBlur? | (event) => void | — | Fired when focus leaves the group entirely — wired to focusout. |
| name? | string | — | Form field name — the group submits one name=value (it's the
form-associated element). |
| label? | string | — | Plain-text label for the whole group, rendered above the options. |
| hint? | string | — | Plain-text description for the group, rendered directly under label (above
the options) — typically instructional copy. Per-option helper text goes on
the option's own hint instead. |
| status? | neutralbrandinfosuccesswarningcritical | 'neutral' | Validation/feedback tone for the group hint — recolors it (same tone set
as Input's status). Use critical for an error message, etc.; omit for
the neutral default. |
| tone? | neutralbrandcriticalinfosuccesswarningstring | 'neutral' | Mark tone applied to every option (an option's own tone wins), or any literal
CSS color for a one-off custom tone. In the default all scope it colors the
selected-ring fill + dot and the unselected ring border. Named tones track
light/dark mode. Set toneScope="selected" to tone only the selected option
and leave the rest neutral. The option
text stays neutral — recolor it in plain CSS via the --text-N-{tone} tokens. |
| toneScope? | allselected | 'all' | Apply tone to every state, or only to the selected option so unselected
rings stay neutral. An option's own toneScope wins. |
| size? | smallmediumlarge | 'medium' | Size applied to every option (an option's own size wins). |
| disabled? | boolean | — | Disable the whole group. |
| orientation? | verticalhorizontal | 'vertical' | Layout + arrow-key axis. |
Inherited props (className, id, slot, style, tabIndex, title)
| Prop | Type | Default | Description |
|---|---|---|---|
| className? | string | — | CSS class on the component's root element (merged with the component's own
classes). Use it directly for layout and positioning — grid/flex placement,
margins, alignment — rather than wrapping the component in a <div>/<span>. |
| id? | string | — | HTML id attribute. |
| slot? | string | — | Assigns the element to a named <slot> of a parent web component (e.g.
slot="header" inside a <Card>, slot="footer" inside a <Dialog>). |
| style? | CSSProperties | — | Inline styles on the component's root element. Set layout/positioning here
(or via className) directly on the component instead of adding a wrapper. |
| tabIndex? | number | — | Tab order. Set to -1 to skip the element when tabbing. |
| title? | string | — | HTML title attribute — native browser tooltip on hover. |
Plus every standard DOM event handler (onClick,
onFocus, onKeyDown, …) and any
data-* or aria-* attribute — forwarded as-is to
the underlying <a-*> element.
Web Component
Use the web component directly when you are not using React or Preact and a native control does not fit.
The group owns selection and the single tab stop. Put options in <a-radio-list>.
<a-radio-group role="radiogroup" tabindex="0" name="plan" default-state="pro"> <a-radio-group-label>Plan</a-radio-group-label> <a-radio-list> <a-radio role="radio" value="starter"><a-radio-label>Starter</a-radio-label></a-radio> <a-radio role="radio" value="pro"><a-radio-label>Pro</a-radio-label></a-radio> </a-radio-list></a-radio-group>Native HTML radio inputs
For a standard HTML form, add data-anta to each native radio. Radios that share
a name retain the browser’s selection, keyboard behavior, labels, and form
submission while using Anta’s control surface.
size, tone, and tone-scope use the matching RadioGroup option
treatments.
<div style="display: grid; gap: 8px"> <label><input data-anta type="radio" name="plan" value="starter" size="small" tone="brand"> Starter</label> <label><input data-anta type="radio" name="plan" value="pro" tone="success" tone-scope="selected" checked> Pro</label> <label><input data-anta type="radio" name="plan" value="enterprise" size="large" tone="warning"> Enterprise</label></div> Styling
Reach for the props first: tone chooses the mark color and toneScope
chooses whether it applies in all states or only to the selected option (any CSS
color can be set on the group or one option and derives the full curve in oklch), size the
dimensions + type. To tint the label + hint, add a color rule on the option’s
a-radio-label / a-radio-hint with the --text-N-{tone} values — there’s no
text-tone prop. The focus ring is the global --focus-ring.
<RadioGroup tone="#e0457b" defaultValue="a" options={[{ value: 'a', label: 'A' }]} />For anything else, <a-radio> is light-DOM — the ring is ::before, the dot
is ::after, the label/hint are the a-radio-label / a-radio-hint children.
Target them with ordinary CSS, per state (an un-layered rule beats @layer anta
without !important). The groups below push it — an outlined (unfilled) selected
ring, square black/white marks, and a bigger ring with a heart-shaped mark (the dot,
masked — same heart as the checkbox):
/* Outlined — an unfilled selected ring: keep the ring background and paint the border + dot in the tone color (light-dark() tunes both themes at once). */a-radio-group.outlined a-radio:state(selected)::before { background: var(--radio-bg); border-color: light-dark(#5f4bc3, #7460d7);}a-radio-group.outlined a-radio:state(selected)::after { background: light-dark(#5f4bc3, #7460d7); }
/* Square — square ring + square dot, black/white per theme via light-dark() */a-radio-group.square a-radio::before,a-radio-group.square a-radio::after { border-radius: 0; }a-radio-group.square a-radio:state(selected)::before { background: light-dark(#000, #fff); border-color: light-dark(#000, #fff); }a-radio-group.square a-radio:state(selected)::after { background: light-dark(#fff, #000); } /* contrasting dot */
/* Heart — a bigger ring with a heart mark (the dot ::after, masked) on a pink fill. Same heart SVG as the checkbox; mask-size: contain centers it in the bigger ring. */a-radio-group.heart a-radio::before { inline-size: 22px; block-size: 22px; } /* bigger ring */a-radio-group.heart a-radio::after { inline-size: 13px; block-size: 13px; border-radius: 0; mask-image: url("data:image/svg+xml,%3Csvg …a heart path… /%3E"); mask-size: contain; mask-repeat: no-repeat; mask-position: center;}a-radio-group.heart a-radio:state(selected)::before { background: light-dark(#ec4899, #f472b6); border-color: transparent; }a-radio-group.heart a-radio:state(selected)::after { background: #fff; } /* white heart */