Component · Core
Radio Group
Overview
A set of mutually exclusive options — exactly one selection from a small, fully visible list. Documented as Radio Group, not a standalone Radio, because an individual radio button only makes sense as part of a set; the group is the real unit of design. Radio Group shares its supporting-text and validation rules with Input's shared form-field model, applied at the group level rather than per-option.
Anatomy
<fieldset> → visible <legend> (group label) → a list of options, stacked vertically by
default with --scale-2 (8px) between rows.
<fieldset>— groups the options.<legend>— the visible group label.- Options — a list of radios, each with its own label.
Group label
Required on every Radio Group — the one structural difference from Checkbox, which carries its own inline label and doesn't need a group-level heading for a single control. The legend names what's being chosen ("Choose a plan"), not a single option.
Option anatomy
Circle (--scale-4, --radius-full) → filled dot (selected) → label, to the right.
Live Example
Default
With a disabled option
Group-level error
Select a role to continue.
Code Example
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
const PLANS = [
{ value: "starter", label: "Starter" },
{ value: "team", label: "Team" },
{ value: "enterprise", label: "Enterprise" },
] as const;
/**
* Live Radio Group example — a native `<fieldset>` + `<legend>`, options
* stacked with `--scale-2` between rows, each circle paired to its own <Label>
* via `htmlFor` / `id`. One option is preselected to demonstrate the selected
* state. Keyboard roving (Tab in/out once, arrows move focus + selection)
* comes from the shared `name` the group supplies — no custom tabindex logic.
*
* The second group shows group-level error messaging: a visible message plus
* `aria-describedby` / `aria-invalid` on the fieldset is the primary signal.
* Radio Group keeps a component-specific `--destructive` error-text treatment
* (system-wide alignment with Input's `--foreground` contract is deferred).
*/
export function RadioGroupShowcase() {
return (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Default
</h3>
<RadioGroup label="Choose a plan">
{PLANS.map((plan) => (
<div key={plan.value} className="flex items-center gap-2">
<RadioGroupItem
id={`plan-${plan.value}`}
value={plan.value}
defaultChecked={plan.value === "team"}
/>
<Label htmlFor={`plan-${plan.value}`}>{plan.label}</Label>
</div>
))}
</RadioGroup>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
With a disabled option
</h3>
<RadioGroup label="Shipping speed">
<div className="flex items-center gap-2">
<RadioGroupItem id="ship-standard" value="standard" defaultChecked />
<Label htmlFor="ship-standard">Standard (3–5 days)</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="ship-express" value="express" />
<Label htmlFor="ship-express">Express (1–2 days)</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="ship-sameday" value="sameday" disabled />
<Label htmlFor="ship-sameday" className="opacity-50">
Same day (unavailable in your area)
</Label>
</div>
</RadioGroup>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Group-level error
</h3>
<div className="flex flex-col gap-1.5">
<RadioGroup
label="Choose a workspace role"
aria-invalid="true"
aria-describedby="role-error"
>
<div className="flex items-center gap-2">
<RadioGroupItem id="role-viewer" value="viewer" />
<Label htmlFor="role-viewer">Viewer</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="role-editor" value="editor" />
<Label htmlFor="role-editor">Editor</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="role-admin" value="admin" />
<Label htmlFor="role-admin">Admin</Label>
</div>
</RadioGroup>
<p id="role-error" className="text-xs leading-snug text-destructive">
Select a role to continue.
</p>
</div>
</section>
</div>
);
}Variants
Layout
Vertical is the baseline — options stack top to bottom so the full set is scannable as a list. Horizontal layout is optional guidance for short 2–3 option sets where a row reads more naturally (e.g. "Yes / No / Not sure") — it's not a baseline-required alternative, just a documented option for the cases it fits.
States
| State | Look |
|---|---|
| Default | --background fill, --input border |
| Focus-visible | --ring on the individual circle on :focus-visible (keyboard focus only), flush against the circle edge — the field focus-ring geometry (Input, Color) |
| Selected | --primary border and centered filled dot |
| Unselected | Default, as above |
| Disabled (per-option) | Reduced opacity (opacity-50; Color — Disabled dimming), native disabled, no pointer events; any single option can be disabled independently of the group |
| Error (group-level) | See Helper text and error messaging — not a per-circle state |
Required validation
Native required on the grouped inputs (all sharing name) triggers browser validation if none
is selected. The resulting error renders at the group level — see below.
Usage Guidance
Default selection
No forced rule — this is a per-instance judgment call, with a guardrail:
- A safe, reversible, recommended default may be preselected (e.g. a suggested plan tier).
- Never preselect an option involving consent, meaningful cost, destructive consequences, or a sensitive user decision — the user must actively choose those.
- When no responsible default exists, leave the group unselected and use required validation if a choice is mandatory.
Helper text and error messaging
Helper and error text both use Input's Caption typography
(text-xs, same for both — only the color differs), applied at the group level:
- Helper text —
--muted-foreground, placed below the full list of options. - Error — a visible error message below the options, in
--destructivetext, is the primary signal — referenced viaaria-describedbyon the fieldset so assistive tech announces it. This message-plus-association pair is what actually communicates the error; it isn't just a nice-to-have alongside a colored border. Radio Group currently retains this component-specific--destructiveerror-text color; it does not yet match Input's canonical--foregrounderror-message contract. That color difference may be reconciled in a future release; the typography is already aligned. - A
--destructiveborder on the option circles may reinforce the message, but it's optional polish, not the signal itself — and it should read as a light touch, not a heavy treatment applied to every circle regardless of whether it adds clarity. - Selection state and error state are never communicated by color alone — the message text and the filled dot shape carry the meaning; color reinforces.
Content guidance
- Legend is a short question or category ("Choose a plan", "Shipping speed").
- Option labels are short noun phrases, sentence case, no trailing colon.
Tokens
Radio Group uses existing Monogem semantic and primitive tokens to stay consistent with the design system; it introduces no component-specific styling values.
| Token | Where used |
|---|---|
--primary | Selected border and dot |
--background | Unselected fill |
--input | Default border |
--ring | Focus-visible ring, per option |
--destructive | Group error text; optional reinforcing border on circles |
--muted-foreground | Group helper text |
--radius-full | Circle shape — the one token difference from Checkbox's --radius-sm box |
--scale-4 | Circle size (16px), same as Checkbox's box |
--scale-2 | Circle-to-label gap; gap between stacked option rows |
--scale-1-5 | Control-to-message gap, same value as Input |
Do / Don’t
Do
- Give every Radio Group a visible
<legend>. - Use grouped radio inputs sharing one
name— that's what makes native keyboard behavior work. - Preselect only a safe, reversible, recommended option — never a consent or high-stakes default.
- Show the group error message as the primary signal; treat a circle border tint as optional reinforcement only.
Don’t
- Use Radio Group for independent booleans — that's one or more Checkboxes.
- Reach for Radio Group once the option set grows past roughly 6–8 visible choices — that's a future Select's job, not this baseline's.
- Rebuild grouping/keyboard behavior with custom JS and ARIA when native
fieldset+ sharednamealready provides it. - Style every circle red on error "to be thorough" — it's heavier than the error needs and isn't the primary signal anyway.
Accessibility
- Native
<fieldset>+<legend>for grouping and labeling; radio inputs share onename. - Each option is paired to its own visible label via
htmlFor/id— see Label. aria-describedbyon the fieldset references the group's currently visible helper/error message id.aria-invalid="true"on the group only while a selection is actually required and missing.- Selected state is never color-only — the filled dot shape is the primary signal.
Keyboard behavior
- Tab moves focus into the group once and back out once — the whole group is a single tab stop, landing on the selected option, or the first option if none is selected yet.
- Arrow keys move focus and selection between options within the group.
This roving behavior comes from the radio inputs sharing the same name attribute — that's
native browser behavior for grouped radios, not something <fieldset>/<legend> provide.
<fieldset> and <legend> supply grouping and labeling semantics (what assistive tech announces
as the group and its name); the shared name is what makes arrow-key roving and single-selection
work. Both are required, but they do different jobs.