Components
Radio group
One choice among a few, all in view: as segments in a row, or as a list with a mark beside each.
MarkdownA radio group is one choice among a few, all in view: choosing one leaves the others unchosen.
A checkout
Tab into either group: focus lands on the chosen one. The arrows move and choose, passing over the courier, which is unavailable, and coming round at the ends. The delivery is a list with a line about each; the wrapping, segments.
Code
"use client";
import { Radio, RadioGroup } from "@publictheta/ui/components/radio-group/radio-group.tsx";
import { useState } from "react";
type Speed = "standard" | "express" | "courier";
type Wrap = "none" | "paper" | "box";
const PRICE: Record<Speed, number> = { standard: 0, express: 9, courier: 24 };
const WRAP: Record<Wrap, number> = { none: 0, paper: 3, box: 6 };
export default function RadioCheckout() {
const [speed, setSpeed] = useState<Speed>("standard");
const [wrap, setWrap] = useState<Wrap>("none");
const total = 48 + PRICE[speed] + WRAP[wrap];
return (
<form className="flex w-full max-w-sm flex-col gap-6" onSubmit={(event) => event.preventDefault()}>
<RadioGroup<Speed>
label="Delivery"
appearance="list"
name="delivery"
value={speed}
onValueChange={setSpeed}
>
<Radio value="standard" description="Three to five working days · Free">
Standard
</Radio>
<Radio value="express" description="Next working day · $9">
Express
</Radio>
<Radio value="courier" description="Not available to this address" disabled>
Same-day courier
</Radio>
</RadioGroup>
<RadioGroup<Wrap> label="Gift wrap" name="wrap" value={wrap} onValueChange={setWrap}>
<Radio value="none">None</Radio>
<Radio value="paper">Paper</Radio>
<Radio value="box">Box</Radio>
</RadioGroup>
<p className="flex justify-between border-t border-border-muted pt-3 text-sm text-fg-strong">
<span>Total</span>
<output className="font-medium tabular-nums">${total}</output>
</p>
</form>
);
}When to use
Use a radio group for two to about seven choices that people should see side by side before choosing — a delivery speed, an alignment, a view. Shown segmented, as a row of a few short choices with the chosen one raised, it suits settings that change what is in view at once; shown as a list, with a mark beside each and room for a line that says more, it suits a form.
For a longer list, or one that must be searched by typing, use a listbox; to save room, a select. For on or off, a checkbox or a toggle button.
Why it is a component
A listbox with single selection already moves a choice with the arrow keys, but it is a different control, with a different contract, and assistive technology announces it as one:
- Role. A radio group is a set of radio buttons; a listbox is a list of options. People who use a screen reader expect each to behave as its role says.
- Keyboard. In a radio group, Tab enters on the chosen one and the next Tab leaves; the arrows of both axes move, and the choice follows focus — round from the last to the first. A listbox has Home and End, typeahead, selection that may not follow focus, multiple selection, and lives in popups.
- Forms. A radio group is the platform's own radio buttons, so a form submits the choice, checks that one is made when it must be, and resets it.
So it is built on the platform's radios, with the keyboard navigation primitive moving the same way in every engine.
Best practices
Label the group, and each choice. The group's label says what is being chosen; each choice's label, what it is. A line that says more is its description, not part of its name.
Choose a sensible default where there is one, so the group is never empty without reason; where a choice must be made, leave it empty and say it is required.
Keep the choices short and parallel. Segments especially: a word or two each.
Disable rather than hide a choice that is unavailable for now.
Platform considerations
Keyboard. Tab enters the group on the chosen one — on the first, while none is — and the next Tab leaves it. Down Arrow and the arrow toward the end of the line move to the next choice and choose it; Up Arrow and the other go back; both mirror in right-to-left text. Past the last, they come round to the first. An unavailable choice is passed over. Space chooses the one with focus. WebKit on macOS leaves radio buttons out of the Tab order unless the person has asked for every control; that is the person's setting, and the group keeps it.
Pointer. A press on a choice or on its label chooses it; a segment is pressed anywhere within it.
Touch. Each segment, and each line of a list, is at least 44 CSS pixels tall.
Forced colours. The chosen segment, raised by shade and shadow elsewhere, takes the system's highlight.
Assistive technology. It is a radio group, named by its label, of radio buttons named by theirs, one of them checked; an unavailable choice is announced as such, and a required group as required.
Behaviour
Semantics
Is a radio group named by its label, of radio buttons named by theirs, the chosen one checked
const screen = await render(<Shipping />);
await expect.element(screen.getByRole("radiogroup", { name: "Delivery" })).toBeVisible();
await expect.element(radio(screen, "Express")).toBeChecked();
await expect.element(radio(screen, "Standard")).not.toBeChecked();
expect(radio(screen, "Express").element().tagName).toBe("INPUT");An unavailable choice is announced as such
const screen = await render(<Shipping />);
await expect.element(radio(screen, "Courier")).toBeDisabled();A choice's description is its description, and not part of its name
const screen = await render(
<RadioGroup label="Delivery" appearance="list">
<Radio value="standard" description="Three to five days">
Standard
</Radio>
</RadioGroup>,
);
await expect.element(radio(screen, "Standard")).toHaveAccessibleName("Standard");
await expect.element(radio(screen, "Standard")).toHaveAccessibleDescription("Three to five days");Has no accessibility violations, segmented or as a list, in every state
const screen = await render(
<>
<Shipping />
<Shipping appearance="list" label="Delivery as a list" />
<Shipping size="small" label="Small" />
<Shipping disabled label="Unavailable" />
</>,
);
await expectAccessible(screen.container);Focus
Tab enters the group on the chosen one, and the next Tab leaves itChromium, Chromium · touch, Firefox
Not yet written.
With nothing chosen, Tab enters on the firstChromium, Chromium · touch, Firefox
Not yet written.
Keyboard
Down and Right move to the next choice and choose it; Up and Left to the one before
const screen = await render(<Shipping defaultValue="standard" />);
await focusOn(screen, "Standard");
await userEvent.keyboard("{ArrowDown}");
await expect.element(radio(screen, "Express")).toHaveFocus();
await expect.element(radio(screen, "Express")).toBeChecked();
await userEvent.keyboard("{ArrowRight}");
await expect.element(radio(screen, "Overnight")).toBeChecked();
await userEvent.keyboard("{ArrowUp}");
await expect.element(radio(screen, "Express")).toBeChecked();
await userEvent.keyboard("{ArrowLeft}");
await expect.element(radio(screen, "Standard")).toHaveFocus();
await expect.element(radio(screen, "Standard")).toBeChecked();An unavailable choice is passed over
const screen = await render(<Shipping />);
await focusOn(screen, "Express");
await userEvent.keyboard("{ArrowRight}");
await expect.element(radio(screen, "Overnight")).toBeChecked();
await expect.element(radio(screen, "Courier")).not.toBeChecked();Past the last, the arrows come round to the first, and back
const screen = await render(<Shipping defaultValue="overnight" />);
await focusOn(screen, "Overnight");
await userEvent.keyboard("{ArrowDown}");
await expect.element(radio(screen, "Standard")).toBeChecked();
await userEvent.keyboard("{ArrowUp}");
await expect.element(radio(screen, "Overnight")).toBeChecked();In right-to-left text, Left goes on and Right goes back
const screen = await render(
<div dir="rtl">
<Shipping defaultValue="standard" />
</div>,
);
await focusOn(screen, "Standard");
await userEvent.keyboard("{ArrowLeft}");
await expect.element(radio(screen, "Express")).toBeChecked();
await userEvent.keyboard("{ArrowRight}");
await expect.element(radio(screen, "Standard")).toBeChecked();
// Down still goes on, as it does in either direction.
await userEvent.keyboard("{ArrowDown}");
await expect.element(radio(screen, "Express")).toBeChecked();With nothing chosen, Space chooses the one with focus
const screen = await render(<Shipping defaultValue={null} />);
await focusOn(screen, "Standard");
await userEvent.keyboard(" ");
await expect.element(radio(screen, "Standard")).toBeChecked();Pointer
A click on a choice, or on its label, chooses it
const screen = await render(<Shipping appearance="list" />);
await userEvent.click(radio(screen, "Standard"));
await expect.element(radio(screen, "Standard")).toBeChecked();
await userEvent.click(screen.getByText("Overnight"));
await expect.element(radio(screen, "Overnight")).toBeChecked();A press anywhere on a segment chooses it
const screen = await render(<Shipping />);
await commands.clickSelector(`label:has(input[value="overnight"])`, { position: { x: 4, y: 4 } });
await expect.element(radio(screen, "Overnight")).toBeChecked();An unavailable choice cannot be chosen
const onValueChange = vi.fn();
const screen = await render(<Shipping onValueChange={onValueChange} />);
await userEvent.click(screen.getByText("Courier"), { force: true });
expect(onValueChange).not.toHaveBeenCalled();
await expect.element(radio(screen, "Express")).toBeChecked();Under a finger each segment, and each line of a list, is 44 pixels tall, and a tap choosesChromium · touch, WebKit · touch
Not yet written.
Appearance
Segmented, the chosen segment is raised from the rest; where colours are forced it takes the system's highlight, and an unavailable one its grey
const screen = await render(<Shipping />);
const chosen = () => getComputedStyle(segment(screen, "Express")).backgroundColor;
const other = () => getComputedStyle(segment(screen, "Standard")).backgroundColor;
expect(chosen()).not.toBe(other());
// Under the pointer too, where the pointer can be.
if (!hasTouch) await userEvent.hover(segment(screen, "Express"));
await commands.forceColors(true);
try {
expect(chosen()).toBe(resolve("Highlight"));
// Its label in the highlight's own text colour, with no plate of the system's behind it.
expect(getComputedStyle(segment(screen, "Express")).color).toBe(resolve("HighlightText"));
// WebKit has no such plate, and no way to ask for none.
if (CSS.supports("forced-color-adjust", "none")) {
expect(getComputedStyle(segment(screen, "Express")).forcedColorAdjust).toBe("none");
}
// And an unavailable one in the system's colour for what is unavailable.
expect(getComputedStyle(segment(screen, "Courier")).color).toBe(resolve("GrayText"));
} finally {
await commands.forceColors(false);
}The chosen one is stated as data-checked and an unavailable one as data-disabled, which its styles read
const screen = await render(<Shipping />);
expect(segment(screen, "Express").hasAttribute("data-checked")).toBe(true);
expect(segment(screen, "Standard").hasAttribute("data-checked")).toBe(false);
expect(segment(screen, "Courier").hasAttribute("data-disabled")).toBe(true);Forms
The choice is submitted under its name
const screen = await render(
<form aria-label="Order">
<Shipping name="delivery" />
</form>,
);
const form = screen.getByRole("form", { name: "Order" }).element() as HTMLFormElement;
expect(new FormData(form).get("delivery")).toBe("express");
await userEvent.click(screen.getByText("Overnight"));
expect(new FormData(form).get("delivery")).toBe("overnight");A required group with nothing chosen keeps its form from being submitted
const screen = await render(
<form aria-label="Order">
<Shipping name="delivery" required defaultValue={null} />
</form>,
);
const form = screen.getByRole("form", { name: "Order" }).element() as HTMLFormElement;
expect(form.checkValidity()).toBe(false);
await expect.element(screen.getByRole("radiogroup")).toHaveAttribute("aria-required", "true");Two groups given no name do not share one
const screen = await render(
<>
<Shipping />
<Shipping label="Return" />
</>,
);
const [first, second] = screen.getByRole("radiogroup").elements();
const name = (group: Element) => group.querySelector("input")!.getAttribute("name");
expect(name(first!)).not.toBe(name(second!));API
A controlled value changes only when its owner changes it, and onValueChange hears each choice
const onValueChange = vi.fn();
function Controlled() {
const [value, setValue] = useState<Speed>("standard");
return (
<>
<Shipping value={value} onValueChange={setValue} />
<RadioGroup label="Fixed" value="a" onValueChange={onValueChange}>
<Radio value="a">Alpha</Radio>
<Radio value="b">Beta</Radio>
</RadioGroup>
</>
);
}
const screen = await render(<Controlled />);
await userEvent.click(screen.getByText("Overnight"));
await expect.element(radio(screen, "Overnight")).toBeChecked();
await userEvent.click(screen.getByText("Beta"));
expect(onValueChange).toHaveBeenCalledWith("b");
await expect.element(radio(screen, "Alpha")).toBeChecked();disabled makes every choice unavailable
const screen = await render(<Shipping disabled />);
for (const name of ["Standard", "Express", "Overnight"]) {
await expect.element(radio(screen, name)).toBeDisabled();
}React
Install
npx shadcn@latest add https://ui.publictheta.com/r/radio-group.jsonSource
src/components/radio-group/radio-group.tsx
"use client";
import { createContext, use, useId, useMemo } from "react";
import type { ComponentProps, KeyboardEvent, ReactElement, ReactNode } from "react";
import { focusRingClassName } from "../../lib/focus-class.ts";
import { cn } from "../../lib/utils.ts";
import { moveForKey, step } from "../../primitives/navigation.ts";
import type { Direction, Move, Orientation } from "../../primitives/navigation.ts";
import { useControllableState } from "../../primitives/use-controllable-state.ts";
export type RadioGroupAppearance = "segmented" | "list";
interface Group {
readonly name: string;
readonly value: string | null;
readonly select: (value: string) => void;
readonly disabled: boolean;
readonly required: boolean;
readonly form: string | undefined;
}
const GroupContext = createContext<Group | null>(null);
export type RadioGroupProps<T extends string> = Omit<
ComponentProps<"div">,
"defaultValue" | "onChange" | "role"
> & {
/** What is chosen; `null` for nothing yet. */
value?: T | null | undefined;
defaultValue?: T | null | undefined;
onValueChange?: ((value: T) => void) | undefined;
/** Shown, and the group's name. Without it, give `aria-label` or `aria-labelledby`. */
label?: ReactNode;
/** `segmented` — a row of a few short choices, the chosen one raised; `list` — choices with a mark each, one to a line. */
appearance?: RadioGroupAppearance | undefined;
/** How the choices are laid out; the arrow keys of both axes move either way. */
orientation?: Orientation | undefined;
size?: "small" | "medium" | undefined;
/** The name it is submitted under. */
name?: string | undefined;
form?: string | undefined;
disabled?: boolean | undefined;
required?: boolean | undefined;
};
/** Keys that move the choice. Home and End are not among them: a radio group, like the platform's, has no ends to go to. */
const ARROWS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
/**
* One choice among a few, all in view: the platform's own radio buttons, so a
* form submits the choice, Tab enters the group on the chosen one, Space
* chooses and assistive technology hears each for what it is. The arrow keys
* are taken over to move the same way in every engine: down and toward the end
* of the line go on, mirrored in right-to-left text; the choice follows, past
* what is unavailable and round from the last to the first.
*/
export function RadioGroup<T extends string>({
value: valueProp,
defaultValue = null,
onValueChange,
label,
appearance = "segmented",
orientation = appearance === "segmented" ? "horizontal" : "vertical",
size = "medium",
name,
form,
disabled = false,
required = false,
className,
children,
onKeyDown,
...props
}: RadioGroupProps<T>): ReactElement {
const ownName = useId();
const labelId = useId();
const [value, setValue] = useControllableState<T | null>(
valueProp,
defaultValue,
onValueChange as ((value: T | null) => void) | undefined,
);
const group = useMemo<Group>(
() => ({
name: name ?? ownName,
value,
select: (next) => setValue(next as T),
disabled,
required,
form,
}),
[name, ownName, value, setValue, disabled, required, form],
);
const onArrow = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
// By what it is rather than by its class: an element of another document is not this one's `HTMLInputElement`.
const target = event.target as HTMLInputElement;
if (event.defaultPrevented || !ARROWS.has(event.key) || !target.matches('input[type="radio"]')) return;
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
const radios = Array.from(
event.currentTarget.querySelectorAll<HTMLInputElement>(`input[type="radio"]`),
).filter((radio) => radio.name === group.name && !radio.disabled);
if (!radios.includes(target)) return;
const direction = getComputedStyle(target).direction as Direction;
const move: Move | null =
moveForKey(event.key, "vertical", direction) ?? moveForKey(event.key, "horizontal", direction);
const next = move === null ? null : step(radios, target, move, { loop: true });
if (next === null) return;
event.preventDefault();
next.focus();
setValue(next.value as T);
};
return (
// Focus is on its radios, each the platform's own: the group itself is never focused.
// oxlint-disable-next-line jsx-a11y/interactive-supports-focus
<div
role="radiogroup"
aria-labelledby={label === undefined ? undefined : labelId}
aria-orientation={orientation}
aria-disabled={disabled || undefined}
aria-required={required || undefined}
data-appearance={appearance}
data-orientation={orientation}
data-size={size}
data-disabled={disabled ? "" : undefined}
className={cn(
"group/radios flex gap-x-3 gap-y-2 text-sm",
appearance === "segmented" ? "items-center" : "flex-col",
className,
)}
onKeyDown={onArrow}
{...props}
>
{label !== undefined && (
<span
id={labelId}
className={cn(
"text-fg-strong group-data-disabled/radios:text-fg-disabled",
appearance === "segmented" ? "text-xs font-medium text-fg-muted" : "font-medium",
)}
>
{label}
</span>
)}
<div
className={cn(
"flex",
orientation === "vertical" ? "flex-col" : "flex-row flex-wrap",
appearance === "segmented"
? "w-fit rounded-lg bg-surface-muted p-0.5 forced-colors:border forced-colors:border-[CanvasText]"
: "gap-2",
appearance === "list" && orientation === "horizontal" && "gap-x-5",
)}
>
<GroupContext value={group}>{children}</GroupContext>
</div>
</div>
);
}
export type RadioProps = Omit<ComponentProps<"label">, "children"> & {
value: string;
disabled?: boolean | undefined;
/** A line under the label that says more; it is the radio's description. */
description?: ReactNode;
children?: ReactNode;
};
/** One choice of a `RadioGroup`: a radio button with its label, which is part of its target. */
export function Radio({ value, disabled = false, description, className, children, ...props }: RadioProps) {
const group = use(GroupContext);
if (group === null) throw new Error("Radio must be rendered inside a RadioGroup.");
const labelId = useId();
const descriptionId = useId();
const checked = group.value === value;
const unavailable = disabled || group.disabled;
return (
<label
data-checked={checked ? "" : undefined}
data-disabled={unavailable ? "" : undefined}
className={cn(
"relative flex cursor-pointer gap-2 select-none [-webkit-tap-highlight-color:transparent] data-disabled:cursor-default data-disabled:text-fg-disabled",
// Segmented: the chosen one raised from the track; the ring drawn on the segment, which is what is seen.
"group-data-[appearance=segmented]/radios:items-center group-data-[appearance=segmented]/radios:justify-center group-data-[appearance=segmented]/radios:rounded-md group-data-[appearance=segmented]/radios:px-3 group-data-[appearance=segmented]/radios:py-1.5 group-data-[appearance=segmented]/radios:text-fg-muted",
"group-data-[size=small]/radios:group-data-[appearance=segmented]/radios:px-2.5 group-data-[size=small]/radios:group-data-[appearance=segmented]/radios:py-1 group-data-[size=small]/radios:text-xs",
"group-data-[appearance=segmented]/radios:not-data-disabled:hover:text-fg-strong",
"group-data-[appearance=segmented]/radios:data-checked:bg-surface-raised group-data-[appearance=segmented]/radios:data-checked:text-fg-strong group-data-[appearance=segmented]/radios:data-checked:shadow-sm",
"group-data-[appearance=segmented]/radios:data-disabled:text-fg-disabled",
"group-data-[appearance=segmented]/radios:has-focus-visible:outline-2 group-data-[appearance=segmented]/radios:has-focus-visible:outline-focus-ring group-data-[appearance=segmented]/radios:has-focus-visible:outline-solid",
// Where the system forces its colours, shade and shadow are gone: the chosen one takes the highlight,
// drawn as given — the system would otherwise put a plate of its own behind the text, the colour of the text —
// and so important, or hover's colours would show through what is no longer forced.
"forced-colors:group-data-[appearance=segmented]/radios:data-checked:bg-[Highlight]! forced-colors:group-data-[appearance=segmented]/radios:data-checked:text-[HighlightText]! forced-colors:group-data-[appearance=segmented]/radios:data-checked:forced-color-adjust-none",
"forced-colors:group-data-[appearance=segmented]/radios:data-disabled:text-[GrayText] forced-colors:group-data-[appearance=list]/radios:data-disabled:text-[GrayText]",
// Under a finger, 44 pixels tall.
"pointer-coarse:group-data-[appearance=segmented]/radios:min-h-11 pointer-coarse:group-data-[appearance=segmented]/radios:px-4 pointer-coarse:group-data-[appearance=segmented]/radios:text-sm",
// List: a mark and its label, the label in line with the mark's middle.
"group-data-[appearance=list]/radios:items-start group-data-[appearance=list]/radios:text-fg-strong group-data-[appearance=list]/radios:data-disabled:text-fg-disabled pointer-coarse:group-data-[appearance=list]/radios:min-h-11 pointer-coarse:group-data-[appearance=list]/radios:py-2.5",
className,
)}
{...props}
>
<input
type="radio"
name={group.name}
form={group.form}
value={value}
checked={checked}
disabled={unavailable}
required={group.required}
// Named by its label alone: the description beside it, inside the same label, is its description.
aria-labelledby={description === undefined ? undefined : labelId}
aria-describedby={description === undefined ? undefined : descriptionId}
onChange={(event) => {
if (event.target.checked) group.select(value);
}}
className={cn(
// Segmented: the input is there for focus, the form and assistive technology; the segment is what is seen.
"group-data-[appearance=segmented]/radios:sr-only",
"group-data-[appearance=list]/radios:mt-0.5 size-4 shrink-0 appearance-none rounded-full border border-border bg-surface",
"enabled:hover:border-fg-muted checked:border-[5px] checked:border-selected enabled:checked:hover:border-selected",
"disabled:border-border-muted disabled:bg-surface-muted disabled:checked:border-fg-disabled",
"forced-colors:checked:border-[Highlight] forced-colors:disabled:border-[GrayText]",
focusRingClassName,
"pointer-coarse:group-data-[appearance=list]/radios:mt-0 pointer-coarse:size-5",
)}
/>
<span className="flex flex-col gap-0.5">
<span id={labelId}>{children}</span>
{description !== undefined && (
<span
id={descriptionId}
className="text-xs text-fg-muted group-data-[appearance=segmented]/radios:sr-only in-data-disabled:text-fg-disabled"
>
{description}
</span>
)}
</span>
</label>
);
}