Radio Group
betaA set of radios where people pick exactly one option, with built-in labels, tones, sizes, and arrow-key movement.
Preview
tone
size
states
details
Plan
row
field
no text
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/radio-group
Use it for
- Picking exactly one option from a short list, like a delivery speed or a plan.
- Two to six options that all fit on screen. People compare them at a glance.
- Pass each option's label as RadioGroupItem children. Clicking the text checks the radio.
- Name the group with aria-label, or aria-labelledby pointing at a visible heading or legend.
- Set tone and size once, on RadioGroup. Every item follows them.
Avoid it for
- Picking any number of options. Use checkboxes.
- Options with a hint, a price, or a consequence. Use SelectionGroup with a SelectionCard for each.
- A setting that turns on or off. Use a switch.
- Long lists, more than about seven options. Use a select or a combobox.
- Starting with nothing checked when one answer is the safe default. Set defaultValue to it.
Props
- RadioGroup
Root. Holds the value. Takes value, defaultValue, onValueChange, name, required, readOnly, and disabled.
RadioGroupProps
- aria-label
RadioGroup. Names the group. TypeScript requires this or aria-labelledby.
string
- aria-labelledby
RadioGroup. The id of visible text that names the group, like a heading or a fieldset legend.
string
- tone
RadioGroup. Color of the checked radio, for every item.
"neutral" | "brand" | "danger" | "success" | "warning" · default "neutral"
- size
RadioGroup. Radio size and label text size, for every item. Density scales them.
"sm" | "md" | "lg" · default "md"
- value
RadioGroup. The checked option, controlled. Use it with onValueChange.
string
- defaultValue
RadioGroup. The option checked at the start when you do not control it.
string
- onValueChange
RadioGroup. Called with the new value when someone picks an option.
(value: string, details) => void
- name
RadioGroup. Form field name. The checked value is sent with the form.
string
- disabled
RadioGroup or RadioGroupItem. Blocks picking. On the group it blocks every item.
boolean · default false
- readOnly
RadioGroup. Shows the value, but no one can change it. Items stay focusable.
boolean · default false
- required
RadioGroup. A form will not send until an option is picked.
boolean · default false
- aria-invalid
RadioGroup. Turns the group to the danger tone. Set it when validation fails, and link the message with aria-describedby.
boolean
- RadioGroupItem
Part. One option. Takes value, which is required, and disabled.
RadioGroupItemProps
- children
RadioGroupItem. Visible label text. Without it, TypeScript requires aria-label, aria-labelledby, or an id that a label points at.
ReactNode
- className
RadioGroup: classes for the group, so className='flex-row flex-wrap' lays items in a row. RadioGroupItem: classes for the circle. Later classes win.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| RadioGroup | RadioGroupProps | - | Root. Holds the value. Takes value, defaultValue, onValueChange, name, required, readOnly, and disabled. |
| aria-label | string | - | RadioGroup. Names the group. TypeScript requires this or aria-labelledby. |
| aria-labelledby | string | - | RadioGroup. The id of visible text that names the group, like a heading or a fieldset legend. |
| tone | "neutral" | "brand" | "danger" | "success" | "warning" | "neutral" | RadioGroup. Color of the checked radio, for every item. |
| size | "sm" | "md" | "lg" | "md" | RadioGroup. Radio size and label text size, for every item. Density scales them. |
| value | string | - | RadioGroup. The checked option, controlled. Use it with onValueChange. |
| defaultValue | string | - | RadioGroup. The option checked at the start when you do not control it. |
| onValueChange | (value: string, details) => void | - | RadioGroup. Called with the new value when someone picks an option. |
| name | string | - | RadioGroup. Form field name. The checked value is sent with the form. |
| disabled | boolean | false | RadioGroup or RadioGroupItem. Blocks picking. On the group it blocks every item. |
| readOnly | boolean | false | RadioGroup. Shows the value, but no one can change it. Items stay focusable. |
| required | boolean | false | RadioGroup. A form will not send until an option is picked. |
| aria-invalid | boolean | - | RadioGroup. Turns the group to the danger tone. Set it when validation fails, and link the message with aria-describedby. |
| RadioGroupItem | RadioGroupItemProps | - | Part. One option. Takes value, which is required, and disabled. |
| children | ReactNode | - | RadioGroupItem. Visible label text. Without it, TypeScript requires aria-label, aria-labelledby, or an id that a label points at. |
| className | string | - | RadioGroup: classes for the group, so className='flex-row flex-wrap' lays items in a row. RadioGroupItem: classes for the circle. Later classes win. |
Accessibility
- The group has role="radiogroup". Each item renders a span with role="radio" plus a hidden native radio, so the value is sent with the form.
- The group is one tab stop. Tab lands on the checked radio, or on the first one when none is checked.
- Arrow keys move the choice and check the radio they land on. They skip disabled items. Space checks the focused radio.
- TypeScript requires a name for the group, and children, aria-label, aria-labelledby, or an id for each item. It cannot check that a label points at the id.
- In server HTML, only aria-label and aria-labelledby name a radio. A label from children or an id is linked once JavaScript runs.
- The unchecked border uses --input and the focus ring uses --ring. The theme tests hold both to 3:1 against the page and cards.
- The dot uses the tone's foreground on its fill, which the theme tests hold to 4.5:1.
- Press scale and the dot's pop-in follow --motion-* tokens. Reduced motion turns both off and keeps the fade.
Examples
Named by a visible heading
<div className="grid gap-3">
<p id="delivery-label" className="text-sm font-medium">
Delivery speed
</p>
<RadioGroup aria-labelledby="delivery-label" name="delivery" defaultValue="standard" tone="brand">
<RadioGroupItem value="standard">Standard, 3 to 5 days</RadioGroupItem>
<RadioGroupItem value="express">Express, next day</RadioGroupItem>
<RadioGroupItem value="pickup">Pick up in store</RadioGroupItem>
</RadioGroup>
</div>A label with a second line
<RadioGroup aria-label="Plan" defaultValue="team">
<RadioGroupItem value="solo">
<span className="block font-medium">Solo</span>
<span className="block text-muted-foreground">One person, three projects.</span>
</RadioGroupItem>
<RadioGroupItem value="team">
<span className="block font-medium">Team</span>
<span className="block text-muted-foreground">Up to ten people, every project.</span>
</RadioGroupItem>
</RadioGroup>In a row
<RadioGroup aria-label="Size" defaultValue="m" className="flex-row flex-wrap gap-x-6">
<RadioGroupItem value="s">Small</RadioGroupItem>
<RadioGroupItem value="m">Medium</RadioGroupItem>
<RadioGroupItem value="l">Large</RadioGroupItem>
</RadioGroup>Controlled
"use client";
import { useState } from "react";
function Theme() {
const [theme, setTheme] = useState("system");
return (
<RadioGroup aria-label="Theme" value={theme} onValueChange={(value) => setTheme(value as string)}>
<RadioGroupItem value="light">Light</RadioGroupItem>
<RadioGroupItem value="dark">Dark</RadioGroupItem>
<RadioGroupItem value="system">Match my device</RadioGroupItem>
</RadioGroup>
);
}Required, in a form, with an error
"use client";
import { useState } from "react";
function DeliverySpeed() {
const [missing, setMissing] = useState(false);
return (
<form
noValidate
className="grid gap-2"
onSubmit={(event) => {
if (!new FormData(event.currentTarget).get("speed")) {
event.preventDefault();
setMissing(true);
}
}}
>
<p id="speed-label" className="text-sm font-medium">
Delivery speed
</p>
<RadioGroup
name="speed"
required
aria-labelledby="speed-label"
aria-invalid={missing || undefined}
aria-describedby={missing ? "speed-error" : undefined}
onValueChange={() => setMissing(false)}
>
<RadioGroupItem value="standard">Standard</RadioGroupItem>
<RadioGroupItem value="express">Express</RadioGroupItem>
</RadioGroup>
{missing && (
<p id="speed-error" className="text-sm text-destructive-soft-foreground">
Pick a delivery speed.
</p>
)}
<button type="submit">Order</button>
</form>
);
}