Skip to content
Monogem

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

Choose a plan

With a disabled option

Shipping speed

Group-level error

Choose a workspace role

Select a role to continue.

Code Example

radio-group-showcase.tsx
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

StateLook
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
UnselectedDefault, 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 --destructive text, is the primary signal — referenced via aria-describedby on 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 --destructive error-text color; it does not yet match Input's canonical --foreground error-message contract. That color difference may be reconciled in a future release; the typography is already aligned.
  • A --destructive border 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.

TokenWhere used
--primarySelected border and dot
--backgroundUnselected fill
--inputDefault border
--ringFocus-visible ring, per option
--destructiveGroup error text; optional reinforcing border on circles
--muted-foregroundGroup helper text
--radius-fullCircle shape — the one token difference from Checkbox's --radius-sm box
--scale-4Circle size (16px), same as Checkbox's box
--scale-2Circle-to-label gap; gap between stacked option rows
--scale-1-5Control-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 + shared name already 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 one name.
  • Each option is paired to its own visible label via htmlFor/id — see Label.
  • aria-describedby on 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.

Related Components / Patterns

  • Checkbox — independent, multi-select choices.
  • Select — one value from a longer list.