Radio group
A set of mutually exclusive options where every choice stays visible.
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
The Quiet Indigo Rule
Anatomy#
- Group. A
role="radiogroup"grid with an 8px gap, named by a legend oraria-labelledby. - Circle. 16px, round, a 1px Field Stroke border. In dark it takes the input color at 30%.
- Dot. An 8px Indigo Paper dot, centered, on the Quiet Indigo fill when checked.
- 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.
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> );}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.
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#
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> );}| State | Treatment |
|---|---|
| Unchecked | Field Stroke border, transparent fill. |
| Checked | Quiet Indigo border and fill with the 8px white dot. |
| Focus visible | Indigo border and a 3px Focus Indigo ring at 50%, on the focused item. In a FieldLabel choice card, the card draws the ring instead. |
| Invalid | With aria-invalid on the items, a red border and a 3px red ring at 20%. A checked invalid item keeps its indigo border. |
| Disabled | 50% opacity and a not-allowed cursor. Disable one item for an option that isn't available, or the group to lock them all. |
| Read-only | readOnly 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 undernamein a native form. - Controlled with
valueandonValueChange(value, eventDetails), or uncontrolled withdefaultValue. The wrapper types the value asany, 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
::after12px wider and 8px taller on each side, a 40 by 32px target. - The group is a one-column grid by default; set
grid-cols-*orflexon it for a row.
Do and don't#
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-labelledbypointing 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'sid. - Link an option's description with
aria-describedbyon 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-invalidon the items and put the message after the group, linked from the group witharia-describedby.
| Keys | Action |
|---|---|
| Tab | Moves 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. |
| Space | Selects the focused item. |
Design tokens#
| Token | Used for |
|---|---|
--input | Unchecked border; 30% fill in dark |
--primary | Checked border and fill |
--primary-foreground | The 8px dot |
--ring | Focus border and 3px ring at 50% |
--destructive | Invalid 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">).
| Prop | Type | Default | Description |
|---|---|---|---|
value | any | No default | The selected value, controlled. |
defaultValue | any | No default | The initially selected value, uncontrolled. |
onValueChange | (value: any, eventDetails: RadioGroup.ChangeEventDetails) => void | No default | Called with the newly selected item's value. |
name | string | No default | Form field name for the submitted value. |
disabled | boolean | false | Disables every item. |
readOnly | boolean | false | Blocks changes; items stay focusable. |
required | boolean | false | A value must be chosen for the form to submit. |
inputRef | React.Ref<HTMLInputElement> | No default | Ref to the hidden input. |
className | string | No default | Merged 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">).
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | any | No default | The value this option stands for. |
id | string | No default | For a <Label htmlFor>. |
disabled | boolean | false | Disables this option only. |
readOnly | boolean | false | Blocks selecting this option. |
required | boolean | false | Marks the option's input required. |
inputRef | React.Ref<HTMLInputElement> | No default | Ref to this option's hidden input. |
className | string | No default | Merged 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.