Radio Group

A radio group lets someone pick exactly one option from a short list.

import { Label } from "@/components/ui/label";
import { Radio, RadioGroup } from "@/components/ui/radio-group";

export default function RadioGroupDemo() {
  return (
    <RadioGroup defaultValue="comfortable" aria-label="Density">
      <Label>
        <Radio value="default" />
        Default
      </Label>
      <Label>
        <Radio value="comfortable" />
        Comfortable
      </Label>
      <Label>
        <Radio value="compact" />
        Compact
      </Label>
      <Label>
        <Radio value="unavailable" disabled />
        Unavailable
      </Label>
    </RadioGroup>
  );
}

The code

The component is a single file, read from the repository when the site is built. Press Show code to see all of it.

import type { ComponentProps } from "react";
import { Radio as BaseRadio } from "@base-ui/react/radio";
import { RadioGroup as BaseRadioGroup } from "@base-ui/react/radio-group";
import { cx } from "@/lib/cx";
import { focusRing } from "@/lib/styles";

export function RadioGroup({
  className,
  ...props
}: Omit<ComponentProps<typeof BaseRadioGroup>, "className"> & { className?: string }) {
  return <BaseRadioGroup className={cx("flex flex-col gap-3", className)} {...props} />;
}

// The selected radio gets an accent ring and dot. A disabled one keeps its dot but greys out
// and never reacts to hover.
const radio = [
  "group inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-full border border-text-secondary",
  "transition-colors duration-150",
  focusRing,
  "not-data-[disabled]:hover:border-text",
  "not-data-[disabled]:data-[checked]:border-accent",
  "data-[disabled]:cursor-not-allowed data-[disabled]:border-border data-[disabled]:bg-surface",
].join(" ");

export function Radio({
  className,
  ...props
}: Omit<ComponentProps<typeof BaseRadio.Root>, "className"> & { className?: string }) {
  return (
    <BaseRadio.Root className={cx(radio, className)} {...props}>
      <BaseRadio.Indicator className="size-2.5 rounded-full bg-accent group-data-[disabled]:bg-text-secondary" />
    </BaseRadio.Root>
  );
}

Props

PropTypeDefaultWhat it does
defaultValuestring—On RadioGroup. The value that starts selected.
valuestring—On RadioGroup. The selected value, when you control it yourself.
onValueChangefunction—On RadioGroup. Called with the new value when the selection changes.
valuestring—On Radio. The value this radio stands for. Required.
disabledbooleanfalseOn Radio, to disable one option, or on RadioGroup, to disable them all.
classNamestring—Extra classes, added after the component's own.

Every other prop goes to the matching Base UI part. Give the group a name with aria-label or aria-labelledby.

The thinking

Radios are for a short list where all the options deserve to be visible and exactly one is true.

  • Built on Base UI: The group owns the selection, so selecting one radio clears the rest. It also handles arrow-key movement.
  • One tab stop: The group is a single stop in the tab order, and the arrow keys move inside it, the way a native radio group does.
  • Short lists only: Past five or six options, a select is easier to scan than a column of radios.

Interface design

  • Size: A 20px circle with a 1px outline in the secondary text color, 4.2:1 against the page.
  • Selected: An accent outline and a 10px accent dot in the middle.
  • Spacing: 12px between options, stacked in a column.
  • Disabled: A surface fill and a hairline border. A disabled option that is selected keeps a grey dot.

Interaction

  • Select: Click a radio or its label's text, or move with the arrow keys once the group has focus.
  • Hover: An unselected radio's outline brightens to the main text color.
  • Focus: Keyboard focus draws a 2px outline, 2px away from the circle.
  • Disabled: It can't be selected, doesn't react to hover, and the arrow keys skip over it.

Empathy

  • Name the group: A screen reader needs the question as well as the options. The demo labels its group with aria-label="Density".
  • Announced as a set: Each radio is read with its label and whether it is selected, inside the group that names it.
  • Target size: The circle alone is 20px, under the 24px minimum of WCAG 2.2 AA. Put each radio in a Label, as the demo does, so the whole row is the target.