Skip to content

Radio group

A set of mutually exclusive options where every choice stays visible.

Status
Stable
Level
Atom
Category
Selection
Adoption
Not used yet
import { RadioGroup } from "@oration/canon/components/radio-group";
packages/canon/src/components/radio-group.tsx

Payment method

How Cedarline pays Halcyon Logistics.

Arrives in 2 business days. No fee.

Mailed to the remit-to address, 5 to 7 days.

Same day. Halcyon pays a 2.5% card fee.

import { Button } from "@oration/canon/components/button";import { Label } from "@oration/canon/components/label";import { RadioGroup, RadioGroupItem } from "@oration/canon/components/radio-group";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() {    const id = React.useId();    const [method, setMethod] = React.useState("ach");    const methods = [        {            value: "ach",            label: "ACH",            description: "Arrives in 2 business days. No fee.",        },        {            value: "check",            label: "Check",            description: "Mailed to the remit-to address, 5 to 7 days.",        },        {            value: "card",            label: "Virtual card",            description: "Same day. Halcyon pays a 2.5% card fee.",        },    ];    return (        <form            className="flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-popover text-left shadow-lg"            onSubmit={(event) => {                event.preventDefault();                const chosen = methods.find(                    (option) => option.value === method,                );                toast.add({                    type: "success",                    title: `Halcyon Logistics is paid by ${chosen?.label ?? method}`,                    description: "From the next payment run on Friday, Oct 2.",                });            }}        >            <div className="flex flex-col gap-1 p-4 pb-2">                <p                    id={`${id}-legend`}                    className="text-base leading-none font-medium text-foreground"                >                    Payment method                </p>                <p className="text-sm text-muted-foreground">                    How Cedarline pays Halcyon Logistics.                </p>            </div>            <RadioGroup                aria-labelledby={`${id}-legend`}                name="payment-method"                value={method}                onValueChange={(value) => setMethod(String(value))}                className="gap-4 p-4"            >                {methods.map((option) => (                    <div key={option.value} className="flex items-start gap-3">                        <RadioGroupItem                            id={`${id}-${option.value}`}                            value={option.value}                            aria-describedby={`${id}-${option.value}-description`}                            className="mt-0.5"                        />                        <div className="flex flex-col gap-1">                            <Label htmlFor={`${id}-${option.value}`}>                                {option.label}                            </Label>                            <p                                id={`${id}-${option.value}-description`}                                className="text-13 text-muted-foreground"                            >                                {option.description}                            </p>                        </div>                    </div>                ))}            </RadioGroup>            <div className="flex items-center justify-end gap-2 border-t border-border bg-muted/50 px-4 py-3">                <Button                    type="button"                    variant="ghost"                    onClick={() => setMethod("ach")}                >                    Reset                </Button>                <Button type="submit">Save method</Button>            </div>        </form>    );}

Usage#

Radio group is a set of mutually exclusive options where every choice stays visible: picking one clears the others. Each item is a 16px circle that fills Quiet Indigo with a white 8px dot when checked. It is built on Base UI RadioGroup and Radio, so arrow keys move and select, and the group submits one value. The mistake to avoid is a group with no name: the group needs a legend or aria-labelledby, and every option its own label.

When to use

  • For choosing exactly one of two to six options when seeing all of them helps the choice: ACH, Check, Virtual card.
  • When each option needs a line of explanation beside it, such as how a queue routes work.
  • For a setting with a default that is always one of a fixed set: When a W-9 expires.
  • As the control inside choice cards, where the whole card is the label.

When not to use

  • For more than six options, or options that don't need to be compared side by side. Use Select
  • For switching the mode of a view, such as List or Board. Use Segmented control
  • For a large, descriptive option with an icon or illustration, drawn as a card. Use Choice card
  • For a single on or off choice. Use Checkbox
  • For choosing several options at once. Use Checkbox

One is always chosen

Start the group with a sensible default selected, usually the most common or the safest option. A group with nothing selected can't be returned to that state once someone clicks, so don't rely on it to mean none.

The Quiet Indigo Rule

The checked circle is selection, one of the few places indigo is spent. Option labels and descriptions stay ink and Slate Meta.

Anatomy#

  1. Group. A role="radiogroup" grid with an 8px gap, named by a legend or aria-labelledby.
  2. Circle. 16px, round, a 1px Field Stroke border. In dark it takes the input color at 30%.
  3. Dot. An 8px Indigo Paper dot, centered, on the Quiet Indigo fill when checked.
  4. Option label. A Label beside the circle, with an optional Slate Meta description below it. Clicking either selects the option.

Examples#

Basic

A fieldset legend names the group and each option is a Label wrapping its item, at weight 400. Arrow keys move and select.

Remittance format
import { Label } from "@oration/canon/components/label";import { RadioGroup, RadioGroupItem } from "@oration/canon/components/radio-group";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Basic() {    const id = React.useId();    const [format, setFormat] = React.useState("pdf");    const formats = [        { value: "pdf", label: "PDF attachment" },        { value: "csv", label: "CSV attachment" },        { value: "portal", label: "Link to the supplier portal" },    ];    return (        <fieldset className="flex flex-col gap-3">            <legend id={`${id}-legend`} className="mb-3 text-sm font-medium">                Remittance format            </legend>            <RadioGroup                aria-labelledby={`${id}-legend`}                value={format}                onValueChange={(value) => {                    setFormat(String(value));                    toast.add({                        title: "Remittance format changed",                        description: formats.find(                            (option) => option.value === value,                        )?.label,                    });                }}                className="gap-3"            >                {formats.map((option) => (                    <Label key={option.value} className="font-normal">                        <RadioGroupItem value={option.value} />                        {option.label}                    </Label>                ))}            </RadioGroup>        </fieldset>    );}

With descriptions

When options need explaining, put the item beside a Label and a Slate Meta description, linked with aria-describedby. The item drops 2px to align with the label's first line.

Approval routing

The person who manages the supplier approves its invoices.

Routed by the GL code on each line. Split invoices go to each owner.

Anyone in Finance can pick it up. Priya Raman is the fallback.

import { Label } from "@oration/canon/components/label";import { RadioGroup, RadioGroupItem } from "@oration/canon/components/radio-group";import * as React from "react";export function WithDescriptions() {    const id = React.useId();    const [routing, setRouting] = React.useState("owner");    const options = [        {            value: "owner",            label: "Supplier owner",            description:                "The person who manages the supplier approves its invoices.",        },        {            value: "cost-center",            label: "Cost center owner",            description:                "Routed by the GL code on each line. Split invoices go to each owner.",        },        {            value: "queue",            label: "Finance queue",            description:                "Anyone in Finance can pick it up. Priya Raman is the fallback.",        },    ];    return (        <div className="flex w-full max-w-md flex-col gap-3">            <p id={`${id}-legend`} className="text-sm font-medium">                Approval routing            </p>            <RadioGroup                aria-labelledby={`${id}-legend`}                value={routing}                onValueChange={(value) => setRouting(String(value))}                className="gap-4"            >                {options.map((option) => (                    <div key={option.value} className="flex items-start gap-3">                        <RadioGroupItem                            id={`${id}-${option.value}`}                            value={option.value}                            aria-describedby={`${id}-${option.value}-description`}                            className="mt-0.5"                        />                        <div className="flex flex-col gap-1">                            <Label htmlFor={`${id}-${option.value}`}>                                {option.label}                            </Label>                            <p                                id={`${id}-${option.value}-description`}                                className="text-13 text-muted-foreground"                            >                                {option.description}                            </p>                        </div>                    </div>                ))}            </RadioGroup>        </div>    );}

In a row

Short, parallel options can sit in a row: set flex on the group. Keep a result line nearby so the choice shows its effect.

Payment terms

INV-20417 would be due Oct 28.

import { Label } from "@oration/canon/components/label";import { RadioGroup, RadioGroupItem } from "@oration/canon/components/radio-group";import * as React from "react";export function Horizontal() {    const id = React.useId();    const [terms, setTerms] = React.useState("30");    return (        <div className="flex flex-col gap-3">            <p id={`${id}-legend`} className="text-sm font-medium">                Payment terms            </p>            <RadioGroup                aria-labelledby={`${id}-legend`}                value={terms}                onValueChange={(value) => setTerms(String(value))}                className="flex flex-wrap gap-x-6 gap-y-3"            >                {["15", "30", "45", "60"].map((days) => (                    <Label key={days} className="font-normal tabular-nums">                        <RadioGroupItem value={days} />                        Net {days}                    </Label>                ))}            </RadioGroup>            <p className="text-13 text-muted-foreground tabular-nums">                INV-20417 would be due{" "}                {terms === "15"                    ? "Oct 13"                    : terms === "30"                      ? "Oct 28"                      : terms === "45"                        ? "Nov 12"                        : "Nov 27"}                .            </p>        </div>    );}

As cards

The whole card is the label; the checked card takes a 2px indigo ring at 60%. For richer cards with icons, use Choice card.

import { RadioGroup, RadioGroupItem } from "@oration/canon/components/radio-group";import * as React from "react";export function Cards() {    const id = React.useId();    const [method, setMethod] = React.useState("ach");    const methods = [        { value: "ach", label: "ACH", description: "2 business days" },        { value: "check", label: "Check", description: "Mailed, 5 to 7 days" },        {            value: "card",            label: "Virtual card",            description: "Same day, 2.5% fee",        },    ];    return (        <RadioGroup            aria-label="Payment method"            value={method}            onValueChange={(value) => setMethod(String(value))}            className="w-full max-w-xl grid-cols-1 gap-2 sm:grid-cols-3"        >            {methods.map((option) => (                <label                    key={option.value}                    htmlFor={`${id}-${option.value}`}                    className="flex cursor-pointer items-start gap-2.5 rounded-xl bg-card p-3 shadow-border transition-shadow duration-150 has-data-checked:ring-2 has-data-checked:ring-primary/60"                >                    <RadioGroupItem                        id={`${id}-${option.value}`}                        value={option.value}                        className="mt-0.5"                    />                    <span className="flex flex-col gap-0.5">                        <span className="text-sm font-medium">                            {option.label}                        </span>                        <span className="text-13 text-muted-foreground">                            {option.description}                        </span>                    </span>                </label>            ))}        </RadioGroup>    );}

Unavailable option

Disable one item and say why in its description. It stays in view so people know the option exists, and arrow keys skip it.

Delivery speed

2 business days

Approve by 1:00 PM CT

Needs a verified bank letter from Northwind Freight.

import { Label } from "@oration/canon/components/label";import { RadioGroup, RadioGroupItem } from "@oration/canon/components/radio-group";import * as React from "react";export function UnavailableOption() {    const id = React.useId();    const [speed, setSpeed] = React.useState("standard");    return (        <div className="flex w-full max-w-sm flex-col gap-3">            <p id={`${id}-legend`} className="text-sm font-medium">                Delivery speed            </p>            <RadioGroup                aria-labelledby={`${id}-legend`}                value={speed}                onValueChange={(value) => setSpeed(String(value))}                className="gap-4"            >                <div className="flex items-start gap-3">                    <RadioGroupItem                        id={`${id}-standard`}                        value="standard"                        className="mt-0.5"                    />                    <div className="flex flex-col gap-1">                        <Label htmlFor={`${id}-standard`}>Standard ACH</Label>                        <p className="text-13 text-muted-foreground">                            2 business days                        </p>                    </div>                </div>                <div className="flex items-start gap-3">                    <RadioGroupItem                        id={`${id}-same-day`}                        value="same-day"                        className="mt-0.5"                    />                    <div className="flex flex-col gap-1">                        <Label htmlFor={`${id}-same-day`}>Same-day ACH</Label>                        <p className="text-13 text-muted-foreground">                            Approve by 1:00 PM CT                        </p>                    </div>                </div>                <div className="flex items-start gap-3">                    <RadioGroupItem                        id={`${id}-wire`}                        value="wire"                        disabled                        aria-describedby={`${id}-wire-description`}                        className="mt-0.5"                    />                    <div className="flex flex-col gap-1 opacity-50">                        <Label htmlFor={`${id}-wire`}>Wire</Label>                        <p                            id={`${id}-wire-description`}                            className="text-13 text-muted-foreground"                        >                            Needs a verified bank letter from Northwind Freight.                        </p>                    </div>                </div>            </RadioGroup>        </div>    );}

Conditional detail

A settings card where one option reveals the detail it needs, indented under its label. The detail disappears, but keeps its value, when another option is chosen.

When a W-9 expires

Suppliers get a renewal request 30 days before either way.

days
import { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { RadioGroup, RadioGroupItem } from "@oration/canon/components/radio-group";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function ConditionalDetail() {    const id = React.useId();    const [policy, setPolicy] = React.useState("grace");    const [days, setDays] = React.useState("14");    return (        <form            className="flex w-full max-w-md flex-col gap-4 rounded-xl bg-card p-4 shadow-border"            onSubmit={(event) => {                event.preventDefault();                toast.add({                    type: "success",                    title: "W-9 policy saved",                    description:                        policy === "grace"                            ? `Payments hold ${days} days after a W-9 expires.`                            : policy === "hold"                              ? "Payments hold as soon as a W-9 expires."                              : "Payments continue when a W-9 expires.",                });            }}        >            <div className="flex flex-col gap-1">                <p id={`${id}-legend`} className="text-sm font-semibold">                    When a W-9 expires                </p>                <p className="text-13 text-muted-foreground">                    Suppliers get a renewal request 30 days before either way.                </p>            </div>            <RadioGroup                aria-labelledby={`${id}-legend`}                name="w9-policy"                value={policy}                onValueChange={(value) => setPolicy(String(value))}                className="gap-3"            >                <Label className="font-normal">                    <RadioGroupItem value="continue" />                    Keep paying                </Label>                <div className="flex flex-col gap-2">                    <Label className="font-normal">                        <RadioGroupItem value="grace" />                        Hold payments after a grace period                    </Label>                    {policy === "grace" ? (                        <div className="flex items-center gap-2 pl-6">                            <Input                                aria-label="Grace period in days"                                inputMode="numeric"                                value={days}                                onChange={(event) =>                                    setDays(event.target.value)                                }                                className="w-16 text-right tabular-nums"                            />                            <span className="text-13 text-muted-foreground">                                days                            </span>                        </div>                    ) : null}                </div>                <Label className="font-normal">                    <RadioGroupItem value="hold" />                    Hold payments right away                </Label>            </RadioGroup>            <Button type="submit" variant="outline" className="self-end">                Save policy            </Button>        </form>    );}

States#

RestFocusInvalidDisabled
Unchecked
Checked
import { RadioGroup, RadioGroupItem } from "@oration/canon/components/radio-group";import { cn } from "@oration/canon/lib/utils";export function StatesMatrix() {    const columns = ["Rest", "Focus", "Invalid", "Disabled"] as const;    const rows = [        { name: "Unchecked", checked: false },        { name: "Checked", checked: true },    ];    return (        <div className="grid w-full min-w-0 grid-cols-[6rem_repeat(4,minmax(0,1fr))] items-center gap-x-2 gap-y-4">            <span />            {columns.map((column) => (                <span                    key={column}                    className="text-center text-xs text-muted-foreground"                >                    {column}                </span>            ))}            {rows.map((row) => (                <div key={row.name} className="contents">                    <span className="text-13 text-muted-foreground">                        {row.name}                    </span>                    {columns.map((column) => (                        <div key={column} className="flex justify-center">                            <RadioGroup                                aria-label={`${row.name}, ${column.toLowerCase()}`}                                defaultValue={row.checked ? "option" : "none"}                                className="w-auto"                            >                                <RadioGroupItem                                    value="option"                                    tabIndex={-1}                                    aria-label="Option"                                    disabled={column === "Disabled"}                                    aria-invalid={                                        column === "Invalid" || undefined                                    }                                    className={cn(                                        "pointer-events-none",                                        column === "Focus" &&                                            "border-ring ring-3 ring-ring/50",                                    )}                                />                            </RadioGroup>                        </div>                    ))}                </div>            ))}        </div>    );}
States
StateTreatment
UncheckedField Stroke border, transparent fill.
CheckedQuiet Indigo border and fill with the 8px white dot.
Focus visibleIndigo border and a 3px Focus Indigo ring at 50%, on the focused item. In a FieldLabel choice card, the card draws the ring instead.
InvalidWith aria-invalid on the items, a red border and a 3px red ring at 20%. A checked invalid item keeps its indigo border.
Disabled50% opacity and a not-allowed cursor. Disable one item for an option that isn't available, or the group to lock them all.
Read-onlyreadOnly on the group blocks changes with no visual change.

Behavior#

  • Base UI renders each item as a <span role="radio"> with a hidden radio input, so the group submits one value under name in a native form.
  • Controlled with value and onValueChange(value, eventDetails), or uncontrolled with defaultValue. The wrapper types the value as any, so narrow it (String(value) or a union check) before storing it.
  • Tab moves into the group on the checked item (or the first, if none is checked) and out again in one stop. Arrow keys move between items and select as they go.
  • Enter does nothing, so pressing it in a form won't change the choice.
  • Items have an invisible ::after 12px wider and 8px taller on each side, a 40 by 32px target.
  • The group is a one-column grid by default; set grid-cols-* or flex on it for a row.

Do and don't#

Do. Start with the most common option selected.
Don't. Start with nothing selected. Once someone clicks, there's no way back to none, and a required group fails silently.
Remittance format
Do. Name the group with a legend that asks the question, and give each option a short label.
Don't. Leave the group unnamed with a heading nearby. Screen readers announce Radio group with no question.

Content#

  • The legend asks the question or names the setting: Payment method, When a W-9 expires.
  • Option labels are parallel and short, one to three words: ACH, Check, Virtual card.
  • Put the difference between options in a Slate Meta description: 2 business days, Same day, 2.5% fee.
  • Order options by frequency or by a natural scale (fastest to slowest), not alphabetically.

Accessibility#

  • Name the group with aria-labelledby pointing at its heading, or put it in a fieldset with a legend.
  • Each item needs its own label: a wrapping <label> or a <Label htmlFor> pointing at the item's id.
  • Link an option's description with aria-describedby on the item, so it is read with the label.
  • A disabled item stays visible and is skipped by arrow keys. Say why it's unavailable in its description.
  • For an error, set aria-invalid on the items and put the message after the group, linked from the group with aria-describedby.
Keyboard interactions
KeysAction
TabMoves focus into the group, onto the checked item, and out again.
↓→Moves to the next enabled item and selects it.
↑←Moves to the previous enabled item and selects it.
SpaceSelects the focused item.

Design tokens#

Design tokens
TokenUsed for
--inputUnchecked border; 30% fill in dark
--primaryChecked border and fill
--primary-foregroundThe 8px dot
--ringFocus border and 3px ring at 50%
--destructiveInvalid border and ring

API reference#

RadioGroup

The group, a Base UI RadioGroup laid out as a one-column grid with an 8px gap. Renders data-slot="radio-group".

Other props spread onto Base UI RadioGroup (<div role="radiogroup">).

Props of RadioGroup
PropTypeDefaultDescription
valueanyNo defaultThe selected value, controlled.
defaultValueanyNo defaultThe initially selected value, uncontrolled.
onValueChange(value: any, eventDetails: RadioGroup.ChangeEventDetails) => voidNo defaultCalled with the newly selected item's value.
namestringNo defaultForm field name for the submitted value.
disabledbooleanfalseDisables every item.
readOnlybooleanfalseBlocks changes; items stay focusable.
requiredbooleanfalseA value must be chosen for the form to submit.
inputRefReact.Ref<HTMLInputElement>No defaultRef to the hidden input.
classNamestringNo defaultMerged after grid w-full gap-2; set columns here.

RadioGroupItem

One option, a Base UI Radio.Root with its Indicator. Renders data-slot="radio-group-item" and gets data-checked or data-unchecked from Base UI.

Other props spread onto Base UI Radio.Root (<span role="radio">).

Props of RadioGroupItem
PropTypeDefaultDescription
valueRequiredanyNo defaultThe value this option stands for.
idstringNo defaultFor a <Label htmlFor>.
disabledbooleanfalseDisables this option only.
readOnlybooleanfalseBlocks selecting this option.
requiredbooleanfalseMarks the option's input required.
inputRefReact.Ref<HTMLInputElement>No defaultRef to this option's hidden input.
classNamestringNo defaultMerged after the base classes, on the circle.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

There is no hover style on the circle, so a pointer gets no response until it clicks.

The product's queue routing cards hand-roll the choice-card pattern (a label with has-data-checked:ring-2) instead of using Choice card or a FieldLabel card.

RadioGroup and RadioGroupItem take Base UI's props without the Value generic, so values are any and every call site casts back to its union (value as Queue["routing"]) with no type checking.