RadioGroup
A vertical list of labelled options where exactly one can be chosen, kept to a single tab stop with a roving focus.
Preview
radio-group · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/radio-group
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { RadioGroup } from "@/components/ui/radio-group"<RadioGroup
label="Reminder style"
value={style}
onValueChange={setStyle}
options={[
{ value: "standard", label: "Standard" },
{ value: "quiet", label: "Quiet" },
{ value: "off", label: "None" },
]}
/>When to use it
Use it when
- A settled single choice of two to about seven options, such as a reminder style.
- A choice whose options each need a line of helper text.
- A choice laid out in full, every option visible at once.
Do not use it when
- One or two word options that switch a parameter of a view, on one line.Use
segmented-controlinstead. - More than about seven options, or long labels, so a select that collapses into one control reads more calmly.Use
fieldinstead. - The choice needs a label, a hint and a validation message wired to it.Use
fieldinstead.
Anatomy
RadioGroupThe root radiogroup: one roving tab stop, neutral chrome only.Controlled byoptionsRadioGroup.LabelThe visible legend, a heading in weight rather than element.Controlled bylabelRadioGroup.ItemOne option row, the pressable target.Controlled byvalueRadioGroup.ControlThe circle, aria-hidden, its ring lifting to the primary ink when checked.RadioGroup.IndicatorThe dot, in the primary ink, only when checked.RadioGroup.ItemLabelThe option's words.RadioGroup.ItemDescriptionThe optional helper line, in the muted ink.Controlled byoptions
- RadioGroup 1data-slot="radio-group", role="radiogroup", one roving tab stop
- RadioGroup.Label 1data-slot="radio-group-label", the visible legend, wired by aria-labelledby
- RadioGroup.Item 2..ndata-slot="radio-group-item", role="radio", aria-checked; one per option
- RadioGroup.Control 1data-slot="radio-group-control", the circle, aria-hidden
- RadioGroup.Indicator 0..1data-slot="radio-group-indicator", the dot, present only when checked
- RadioGroup.ItemLabel 1data-slot="radio-group-item-label"
- RadioGroup.ItemDescription 0..1data-slot="radio-group-item-description", present only when the option has a description
- RadioGroup.Control 1data-slot="radio-group-control", the circle, aria-hidden
Examples
A single choice
Three options, one chosen. options carries the list, value marks the chosen one.
radio-group-a-single-choice · base base · style base-lyraOpen under the product themeOptions with descriptions
The same list with a description under each label.
radio-group-with-descriptions · base base · style base-lyraOpen under the product themeContent guidelines
Labels are short, parallel and in sentence case, naming the value each option sets. Order them the way the reader thinks, and keep an option's wording the same on every screen.
Do
"Standard" · "Quiet" · "None" are parallel single words, ordered from most to least, so they read as siblings.
Don’t
"Standard reminders" · "Keep it quiet" · "I want nothing" are three grammars, so they stop reading as one set.
Accessibility
This was audited against WCAG 2.2 AA in a source pass and a rendered pass on
/view/base/base-lyra/component/radio-group. The audit is author-run, not an independent review, and clinical review is still
pending.
The audit found nothing to change. The rendered pass was clean: zero axe
violations, rows floor at --opsin-target-minimum, focus stays visible, no
sideways scroll at 320px. The source pass confirmed one roving tab stop, a visible
legend wired by aria-labelledby rather than an aria-label, and selection
carried three ways through aria-checked, the lifted ring and the filled dot,
never on colour alone.
Two residual notes. An option's description renders inside the radio, so the
accessible name folds it in rather than exposing it through aria-describedby. It is
announced and passes AA; wiring it as a distinct description restructures
the name, a redesign rather than a fix. The colour transition on the row is not
motion under 2.3.3, and its prefers-reduced-motion handling lives in global
product CSS. Every colour is a role token, so no contrast ratio reads from the
source; the measured pairs are the report below.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves focus into the control, onto the selected option | With nothing selected, the first enabled option takes it. |
| Shift+Tab | Moves focus back out of the control | One stop in reverse too. |
| Arrow Down, Arrow Right | Moves to the next option and selects it | Wraps, skips disabled. Left and Right swap under right-to-left. |
| Arrow Up, Arrow Left | Moves to the previous option and selects it | Wraps, skips disabled options. |
| Space | Selects the focused option | The activator of a role="radio". |
| Enter | Does nothing | The primitive cancels Enter, so no form submits by accident. |
| Home, End | Not bound | The primitive does not enable them. The browser default stands. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
API reference
Prop
Type
Generated from RadioGroupProps in registry/bases/base/radio-group.tsx.
value and onValueChange make this controlled, with no internal state. A value
matching no option renders nothing chosen and warns in development. A disabled
option stays in the list, drawn in the muted ink and skipped by the Arrow keys. No
options renders nothing.
Related
- SegmentedControl is the same single choice drawn as one compact row, for switching a view.
- Select collapses the options into one control that opens on demand.
- Field wraps an input with its label, hint and validation message.