Qure UI
Components

Radio Group

Selects exactly one option from a set, with every option visible at once.

'use client'import { Label } from '@/registry/qure/ui/label'import { RadioGroup, RadioGroupItem } from '@/registry/qure/ui/radio-group'export default function RadioGroupDemo() {  return (    <RadioGroup defaultValue="routine">      <Label><RadioGroupItem value="routine" /> Routine</Label>      <Label><RadioGroupItem value="urgent" /> Urgent</Label>      <Label><RadioGroupItem value="stat" /> STAT</Label>      <Label><RadioGroupItem value="research" disabled /> Research only</Label>    </RadioGroup>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/radio-group.json

Usage

import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
<RadioGroup defaultValue="routine" aria-label="Priority">
  <Label><RadioGroupItem value="routine" /> Routine</Label>
  <Label><RadioGroupItem value="urgent" /> Urgent</Label>
</RadioGroup>

Wrapping each item in a Label makes the text a click target as well as the dot, which matters more than it sounds: an 18px circle is a poor target, and the words beside it are already where the pointer is going.

A radio group needs a name. Put a FieldLabel above it, or an aria-label on the group when the surrounding layout already reads as a heading. Without one, a screen reader announces four radio buttons and never says what they are choosing between.

Radio, checkbox, or select?

UseWhen
RadioGroupOne choice from two to about six, all worth seeing at once — priority, laterality, second-read policy
CheckboxAny number of choices, including none
SelectOne choice from a closed list too long to show, or a form dense enough that six rows of radios would swamp it
ToggleGroupOne choice that changes the view immediately, and reads as a control rather than a form field

A radio group cannot be emptied once set. That is the platform behaviour, not something we could change, so a group whose answer is genuinely optional needs a default or an explicit "None" option — never an unset group the reader is unable to return to.

Layout

import { Label } from '@/registry/qure/ui/label'import { RadioGroup, RadioGroupItem } from '@/registry/qure/ui/radio-group'export default function RadioGroupHorizontal() {  return (    <RadioGroup defaultValue="left" aria-label="Laterality" className="grid-flow-col gap-6">      <Label><RadioGroupItem value="left" /> Left</Label>      <Label><RadioGroupItem value="right" /> Right</Label>      <Label><RadioGroupItem value="bilateral" /> Bilateral</Label>    </RadioGroup>  )}

The group is a grid with a 10px gap, stacked. For a short set of one-word options, add grid-flow-col and a wider gap to put them on one line. Do not do this with long labels: the distance between a label and the next option's dot shrinks until the reader has to guess which dot belongs to which word.

Descriptions per option

The report is final once the first reader signs it.

A second radiologist reads the study before the report leaves the department.

Requires three readers. Unavailable while the department is short-staffed.

'use client'import { Field, FieldDescription, FieldItem, FieldLabel } from '@/registry/qure/ui/field'import { RadioGroup, RadioGroupItem } from '@/registry/qure/ui/radio-group'export default function RadioGroupDescriptions() {  return (    <Field className="max-w-md">      <FieldLabel>Second read</FieldLabel>      <RadioGroup defaultValue="peer" name="second-read" className="gap-0">        <FieldItem>          <FieldLabel>            <RadioGroupItem value="none" />            No second read          </FieldLabel>          <FieldDescription>The report is final once the first reader signs it.</FieldDescription>        </FieldItem>        <FieldItem>          <FieldLabel>            <RadioGroupItem value="peer" />            Peer review          </FieldLabel>          <FieldDescription>            A second radiologist reads the study before the report leaves the department.          </FieldDescription>        </FieldItem>        <FieldItem disabled>          <FieldLabel>            <RadioGroupItem value="arbitration" />            Arbitration          </FieldLabel>          <FieldDescription>            Requires three readers. Unavailable while the department is short-staffed.          </FieldDescription>        </FieldItem>      </RadioGroup>    </Field>  )}

FieldItem gives each option its own label and description, indented past the dot so the description lines up with the label rather than the control. Reach for it when the options have consequences a reader could not guess from three words — which, for anything clinical, is most of them.

disabled on a FieldItem disables the radio inside it and greys the whole row, so the reason stays legible next to the option it applies to.

Disabled and read-only

One option disabled
Whole group disabled
Read-only
import { Label } from '@/registry/qure/ui/label'import { RadioGroup, RadioGroupItem } from '@/registry/qure/ui/radio-group'export default function RadioGroupState() {  return (    <div className="flex flex-wrap gap-12">      <div className="flex flex-col gap-3">        <span className="text-muted-foreground text-xs font-medium">One option disabled</span>        <RadioGroup defaultValue="ct" aria-label="Modality">          <Label><RadioGroupItem value="ct" /> CT</Label>          <Label><RadioGroupItem value="mr" /> MR</Label>          <Label><RadioGroupItem value="pet" disabled /> PET — scanner offline</Label>        </RadioGroup>      </div>      <div className="flex flex-col gap-3">        <span className="text-muted-foreground text-xs font-medium">Whole group disabled</span>        <RadioGroup defaultValue="urgent" disabled aria-label="Priority, locked">          <Label><RadioGroupItem value="routine" /> Routine</Label>          <Label><RadioGroupItem value="urgent" /> Urgent</Label>          <Label><RadioGroupItem value="stat" /> STAT</Label>        </RadioGroup>      </div>      <div className="flex flex-col gap-3">        <span className="text-muted-foreground text-xs font-medium">Read-only</span>        <RadioGroup defaultValue="final" readOnly aria-label="Report status">          <Label><RadioGroupItem value="preliminary" /> Preliminary</Label>          <Label><RadioGroupItem value="final" /> Final</Label>          <Label><RadioGroupItem value="amended" /> Amended</Label>        </RadioGroup>      </div>    </div>  )}

Three different statements, and they are not interchangeable:

  • One item disabled — that option exists but is unavailable. Keep the reason in the label; an option that silently vanishes is a worse answer than one that explains itself.
  • The group disabled — this decision is not yours to make here.
  • readOnly — the value is settled and is being shown to you. The group stays focusable and the value stays selectable text, which a disabled group does not.

In a field

Recorded against the order and visible to the referrer.

'use client'import * as React from 'react'import { Button } from '@/registry/qure/ui/button'import { Field, FieldDescription, FieldError, FieldLabel } from '@/registry/qure/ui/field'import { Label } from '@/registry/qure/ui/label'import { RadioGroup, RadioGroupItem } from '@/registry/qure/ui/radio-group'export default function RadioGroupField() {  const [reason, setReason] = React.useState<string | null>(null)  const [submitted, setSubmitted] = React.useState(false)  const missing = submitted && reason === null  return (    <Field className="max-w-sm" invalid={missing}>      <FieldLabel>Reason for cancelling</FieldLabel>      <FieldDescription>Recorded against the order and visible to the referrer.</FieldDescription>      <RadioGroup        value={reason}        onValueChange={value => setReason(value as string)}        aria-label="Reason for cancelling"        required      >        <Label><RadioGroupItem value="duplicate" /> Duplicate order</Label>        <Label><RadioGroupItem value="declined" /> Patient declined</Label>        <Label><RadioGroupItem value="unsuitable" /> Contraindicated</Label>      </RadioGroup>      {/* match={boolean} hands visibility to us, which is what a group of          radios needs: there is no control to blur, so no validity event. */}      <FieldError match={missing}>Choose a reason before cancelling the order.</FieldError>      <Button size="sm" variant="secondary" className="mt-1" onClick={() => setSubmitted(true)}>        Cancel order      </Button>    </Field>  )}

A radio group inside a Field gets its label, description and error placement from the field. Validation needs a hand, though: there is no blur to hang a check on when the whole group is one tab stop, so drive the error yourself with invalid on the field and match={boolean} on FieldError rather than waiting for a validity event that will not come.

Keyboard

Arrow keys move and select within the group, and the group is a single tab stop — Tab moves past it rather than through it. That is the platform behaviour for radios and it is why this should never be built out of buttons. A disabled item is skipped by the arrows.

Because moving the focus also changes the value, do not attach anything expensive or irreversible to onValueChange: a keyboard user passes through every option on the way to the last one.

Styling

Checked state is drawn as a thick border rather than an inner dot, so there is one element to animate and no risk of the dot and ring disagreeing at small sizes:

.cn-radio[data-checked] { border-color: var(--background-brand-default); border-width: 5px; }

The resting outline is --color-input (border-base-tertiary), the lightest token that clears the 3:1 WCAG 2.2 SC 1.4.11 asks of a control boundary. An unchecked radio is nothing but its boundary, so do not soften it.

API Reference

Everything Base UI's Radio Group accepts. The ones you will reach for, on RadioGroup:

Prop

Type

On RadioGroupItem:

Prop

Type

The item renders a <span> plus a hidden <input type="radio">, which is what carries the value into a form.

On this page