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
| Prop | Type | Default | What it does |
|---|---|---|---|
defaultValue | string | — | On RadioGroup. The value that starts selected. |
value | string | — | On RadioGroup. The selected value, when you control it yourself. |
onValueChange | function | — | On RadioGroup. Called with the new value when the selection changes. |
value | string | — | On Radio. The value this radio stands for. Required. |
disabled | boolean | false | On Radio, to disable one option, or on RadioGroup, to disable them all. |
className | string | — | 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.