---
title: "RadioGroup"
description: "A vertical list of labelled options where exactly one can be chosen, kept to a single tab stop with a roving focus."
url: "https://opsinjs.pensievelabs.org/components/radio-group"
source: "https://opsinjs.pensievelabs.org/components/radio-group.md"
section: "Components"
status: "shipped"
kind: "component"
category: "actions-and-forms"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["radio buttons", "single choice"]
usedIn: ["diabetes-medicines-app"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="radio-group"
  status="shipped"
  questions="[
  &#x22;No contrast pair has been measured, so whether the primary ring, dot and disabled muted ink clear the floors is unknown.&#x22;,
  &#x22;Nobody has listened to it in a screen reader, so whether a description reads usefully as part of its option is unheard.&#x22;,
  &#x22;Forced colours and prefers-contrast: more are unhandled, so whether the selection survives when the platform replaces the neutral ring and dot has not been tested.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="radio-group" />

## Installation [#installation]

<ComponentInstall name="radio-group" unbuilt="false" importPath="@/components/ui/radio-group" dependencies="[&#x22;@base-ui/react&#x22;]" />

## Usage [#usage]

```tsx
import { RadioGroup } from "@/components/ui/radio-group"
```

```tsx
<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 [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;A settled single choice of two to about seven options, such as a reminder style.&#x22;,
  &#x22;A choice whose options each need a line of helper text.&#x22;,
  &#x22;A choice laid out in full, every option visible at once.&#x22;,
]"
  avoid="[
  { case: &#x22;One or two word options that switch a parameter of a view, on one line.&#x22;, instead: &#x22;segmented-control&#x22; },
  { case: &#x22;More than about seven options, or long labels, so a select that collapses into one control reads more calmly.&#x22;, instead: &#x22;field&#x22; },
  { case: &#x22;The choice needs a label, a hint and a validation message wired to it.&#x22;, instead: &#x22;field&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="radio-group"
  parts="[
  {
    name: &#x22;RadioGroup&#x22;,
    describes: &#x22;The root radiogroup: one roving tab stop, neutral chrome only.&#x22;,
    prop: &#x22;options&#x22;,
  },
  {
    name: &#x22;RadioGroup.Label&#x22;,
    describes: &#x22;The visible legend, a heading in weight rather than element.&#x22;,
    prop: &#x22;label&#x22;,
  },
  {
    name: &#x22;RadioGroup.Item&#x22;,
    describes: &#x22;One option row, the pressable target.&#x22;,
    prop: &#x22;value&#x22;,
  },
  {
    name: &#x22;RadioGroup.Control&#x22;,
    describes: &#x22;The circle, aria-hidden, its ring lifting to the primary ink when checked.&#x22;,
  },
  {
    name: &#x22;RadioGroup.Indicator&#x22;,
    describes: &#x22;The dot, in the primary ink, only when checked.&#x22;,
  },
  {
    name: &#x22;RadioGroup.ItemLabel&#x22;,
    describes: &#x22;The option's words.&#x22;,
  },
  {
    name: &#x22;RadioGroup.ItemDescription&#x22;,
    describes: &#x22;The optional helper line, in the muted ink.&#x22;,
    prop: &#x22;options&#x22;,
  },
]"
/>

<CompositionTree
  name="radio-group"
  tree="[
  {
    part: &#x22;RadioGroup&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;data-slot=\&#x22;radio-group\&#x22;, role=\&#x22;radiogroup\&#x22;, one roving tab stop&#x22;,
    children: [
      {
        part: &#x22;RadioGroup.Label&#x22;,
        cardinality: &#x22;1&#x22;,
        note: &#x22;data-slot=\&#x22;radio-group-label\&#x22;, the visible legend, wired by aria-labelledby&#x22;,
      },
      {
        part: &#x22;RadioGroup.Item&#x22;,
        cardinality: &#x22;2..n&#x22;,
        note: &#x22;data-slot=\&#x22;radio-group-item\&#x22;, role=\&#x22;radio\&#x22;, aria-checked; one per option&#x22;,
        children: [
          {
            part: &#x22;RadioGroup.Control&#x22;,
            cardinality: &#x22;1&#x22;,
            note: &#x22;data-slot=\&#x22;radio-group-control\&#x22;, the circle, aria-hidden&#x22;,
            children: [
              {
                part: &#x22;RadioGroup.Indicator&#x22;,
                cardinality: &#x22;0..1&#x22;,
                note: &#x22;data-slot=\&#x22;radio-group-indicator\&#x22;, the dot, present only when checked&#x22;,
              },
            ],
          },
          {
            part: &#x22;RadioGroup.ItemLabel&#x22;,
            cardinality: &#x22;1&#x22;,
            note: &#x22;data-slot=\&#x22;radio-group-item-label\&#x22;&#x22;,
          },
          {
            part: &#x22;RadioGroup.ItemDescription&#x22;,
            cardinality: &#x22;0..1&#x22;,
            note: &#x22;data-slot=\&#x22;radio-group-item-description\&#x22;, present only when the option has a description&#x22;,
          },
        ],
      },
    ],
  },
]"
/>

## Examples [#examples]

### A single choice [#a-single-choice]

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

<ComponentPreview name="radio-group-a-single-choice" kind="example" align="start" />

### Options with descriptions [#options-with-descriptions]

The same list with a `description` under each label.

<ComponentPreview name="radio-group-with-descriptions" kind="example" align="start" />

## Content guidelines [#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.

<DoDont>
  <DoDont.Do>
    **"Standard" · "Quiet" · "None"** are parallel single words, ordered from most
    to least, so they read as siblings.
  </DoDont.Do>

  <DoDont.Dont>
    **"Standard reminders" · "Keep it quiet" · "I want nothing"** are three
    grammars, so they stop reading as one set.
  </DoDont.Dont>
</DoDont>

## Accessibility [#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.

<KeyboardTable
  name="radio-group"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus into the control, onto the selected option&#x22;,
    notes: &#x22;With nothing selected, the first enabled option takes it.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves focus back out of the control&#x22;,
    notes: &#x22;One stop in reverse too.&#x22;,
  },
  {
    keys: &#x22;Arrow Down, Arrow Right&#x22;,
    action: &#x22;Moves to the next option and selects it&#x22;,
    notes: &#x22;Wraps, skips disabled. Left and Right swap under right-to-left.&#x22;,
  },
  {
    keys: &#x22;Arrow Up, Arrow Left&#x22;,
    action: &#x22;Moves to the previous option and selects it&#x22;,
    notes: &#x22;Wraps, skips disabled options.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Selects the focused option&#x22;,
    notes: &#x22;The activator of a role=\&#x22;radio\&#x22;.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Does nothing&#x22;,
    notes: &#x22;The primitive cancels Enter, so no form submits by accident.&#x22;,
  },
  {
    keys: &#x22;Home, End&#x22;,
    action: &#x22;Not bound&#x22;,
    notes: &#x22;The primitive does not enable them. The browser default stands.&#x22;,
  },
]"
/>

<ContrastReport component="radio-group" />

## API reference [#api-reference]

<PropsTable name="RadioGroupProps" />

`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 [#related]

* [SegmentedControl](./segmented-control.mdx) is the same single choice drawn as one
  compact row, for switching a view.
* [Select](./select.mdx) collapses the options into one control that opens on
  demand.
* [Field](./field.mdx) wraps an input with its label, hint and validation message.
