opsinjs
ComponentsActions and forms

RadioGroup

A vertical list of labelled options where exactly one can be chosen, kept to a single tab stop with a roving focus.

Preview

Theme
Density
Text
Status
radio-group · base base · style base-lyraOpen under the product theme

Installation

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-control instead.
  • More than about seven options, or long labels, so a select that collapses into one control reads more calmly.Use field instead.
  • The choice needs a label, a hint and a validation message wired to it.Use field instead.

Anatomy

  1. RadioGroupThe root radiogroup: one roving tab stop, neutral chrome only.Controlled by options
  2. RadioGroup.LabelThe visible legend, a heading in weight rather than element.Controlled by label
  3. RadioGroup.ItemOne option row, the pressable target.Controlled by value
  4. RadioGroup.ControlThe circle, aria-hidden, its ring lifting to the primary ink when checked.
  5. RadioGroup.IndicatorThe dot, in the primary ink, only when checked.
  6. RadioGroup.ItemLabelThe option's words.
  7. RadioGroup.ItemDescriptionThe optional helper line, in the muted ink.Controlled by options
  • 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

Examples

A single choice

Three options, one chosen. options carries the list, value marks the chosen one.

Theme
Density
Text
Status
radio-group-a-single-choice · base base · style base-lyraOpen under the product theme

Options with descriptions

The same list with a description under each label.

Theme
Density
Text
Status
radio-group-with-descriptions · base base · style base-lyraOpen under the product theme

Content 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.

KeyActionNotes
TabMoves focus into the control, onto the selected optionWith nothing selected, the first enabled option takes it.
Shift+TabMoves focus back out of the controlOne stop in reverse too.
Arrow Down, Arrow RightMoves to the next option and selects itWraps, skips disabled. Left and Right swap under right-to-left.
Arrow Up, Arrow LeftMoves to the previous option and selects itWraps, skips disabled options.
SpaceSelects the focused optionThe activator of a role="radio".
EnterDoes nothingThe primitive cancels Enter, so no form submits by accident.
Home, EndNot boundThe primitive does not enable them. The browser default stands.
PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below 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.

  • 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.

On this page