A radio group (WAI-ARIA Radio Group pattern, native radio variant): a <fieldset> of real <input type="radio"> elements. The platform supplies the single tab stop, arrow keys (wrapping and RTL-mirrored outside Safari), forms and validation. The library adds one thing: the checked value is changed through valueChangeAction in a transition, with optimistic feedback and data-pending.
"use client";
import { RadioGroup } from "@funstack/ui/radio-group";
import { fakeSave } from "./fake-api.js";
const themes = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
];
// Uncontrolled with an async save. The clicked radio checks at once (its DOM `checked` is the
// optimistic value); the Root and that radio carry data-pending for the 800 ms the save takes.
// The legend reads `pending` through the Label's function children; the row spinner is CSS on
// `:has([data-pending])`. Arrow keys are native: each press checks and saves, the latest wins.
export function RadioGroupTheme() {
return (
<RadioGroup.Root
className="demo-radio-group"
defaultValue="system"
valueChangeAction={(next) => fakeSave(`theme=${next}`)}
>
<RadioGroup.Label>
{({ pending }) => (
<>Theme {pending && <span className="demo-radio-saving">saving…</span>}</>
)}
</RadioGroup.Label>
{themes.map((theme) => (
<label key={theme.value} className="demo-field">
<RadioGroup.Item value={theme.value} className="demo-radio" />
{theme.label}
<span className="demo-spinner demo-radio-spinner" aria-hidden />
</label>
))}
</RadioGroup.Root>
);
}/* RadioGroup: the Root is a <fieldset>, the Label its <legend>, each Item the visible radio
(appearance: none + ::before). `:checked` and `[data-checked]` agree: the DOM `checked` is the
optimistic value. */
.demo-radio-group {
display: grid;
gap: 0.4rem;
justify-items: start;
margin: 0;
padding: 0.5rem 1rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
}
.demo-radio-group legend {
padding-inline: 0.3rem;
font-weight: 600;
color: var(--color-text);
}
.demo-radio-group[data-pending] {
cursor: progress;
}
.demo-radio-inline {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
}
.demo-radio {
appearance: none;
flex: none;
margin: 0;
inline-size: 1.25em;
block-size: 1.25em;
border: 2px solid var(--color-text-secondary);
border-radius: 50%;
display: inline-grid;
place-content: center;
cursor: inherit;
transition: border-color 120ms;
}
.demo-radio::before {
content: "";
inline-size: 0.6em;
block-size: 0.6em;
border-radius: 50%;
background: var(--color-primary);
scale: 0;
transition: scale 120ms;
}
.demo-radio:checked {
border-color: var(--color-primary);
}
/* Optimistic: flips on click, reverts on rejection or veto. */
.demo-radio:checked::before {
scale: 1;
}
.demo-radio:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.demo-radio:user-invalid {
border-color: #dc2626;
}
.demo-field:has(.demo-radio:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
/* The row spinner shows only next to the radio the user is waiting on. */
.demo-radio-spinner {
display: none;
}
.demo-field:has(.demo-radio[data-pending]) .demo-radio-spinner {
display: inline-block;
}
.demo-radio-saving {
font-weight: normal;
font-size: 0.85em;
color: var(--color-text-secondary);
}
@media (prefers-reduced-motion: reduce) {
.demo-radio,
.demo-radio::before {
transition: none;
}
}import { RadioGroup } from "@funstack/ui/radio-group";
<RadioGroup.Root name="plan" defaultValue="free" valueChangeAction={savePlan}> {/* fieldset */}
<RadioGroup.Label>Plan</RadioGroup.Label> {/* legend: optional, first child */}
<label>
<RadioGroup.Item value="free" /> Free {/* input type=radio */}
</label>
<label>
<RadioGroup.Item value="pro" /> Pro
</label>
</RadioGroup.Root><fieldset role="radiogroup">: the legend names it, disabled reaches every radio natively, and it finds the form for resets. A render element must stay a <fieldset>.<legend> and must be the Root's first child. Its function children receive the Root's state, so a group-level pending indicator goes there.appearance: none and ::before, or use accent-color. Each Item's text label is your own <label>.RadioGroup.Root| Prop | Type | Default | Description |
|---|---|---|---|
value | string | null | – | Controlled checked value; null checks nothing. undefined means uncontrolled. |
defaultValue | string | null | null | Initial value in uncontrolled mode. Also what a form reset returns to: the latest value is mirrored into the radios' defaultChecked. An unchecked group is the HTML default, so leaving both out is fine. |
valueChangeAction | ChangeAction<string | null, RadioGroupValueChangeDetails> | – | Runs in the Root's transition whenever the checked radio changes; sync, async or a Server Function. Rejections reach the nearest Error Boundary and roll the selection back. next is a string for every user interaction; it is null only with reason: "platform", when a form reset empties the group. Every arrow key press dispatches; the latest wins. |
name | string | useId() | The name shared by every radio: the form data key. Pass a real name inside a form you submit. Must be unique within the form. |
required | boolean | false | Marks every radio required, which makes the group required; one checked radio satisfies it. |
disabled | boolean | false | Renders <fieldset disabled>: every radio is disabled, unfocusable and skipped by the arrow keys. |
form | string | – | The form attribute, applied to the fieldset and to every radio. |
className | string | ((state: RadioGroupRootState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: RadioGroupRootState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: RadioGroupRootState) => ReactNode) | – | The Label and Items. The function form receives the optimistic value; must not suspend. |
render | ReactElement | ((props, state: RadioGroupRootState) => ReactNode) | – | Replace the default <fieldset> (element form) or take over rendering (function form). |
RadioGroupRootState: { value, pending, disabled }, with value optimistic. The Root owns role. Other fieldset props pass through (aria-label for a group without a Label, for example).
RadioGroup.Label| Prop | Type | Default | Description |
|---|---|---|---|
className | string | ((state: RadioGroupRootState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: RadioGroupRootState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: RadioGroupRootState) => ReactNode) | – | Content, or a function of the Root's state. |
render | ReactElement | ((props, state: RadioGroupRootState) => ReactNode) | – | Replace the default <legend> (element form) or take over rendering (function form). |
RadioGroup.Item| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | Required. The value this radio checks, unique within the Root; submitted as the form value. |
disabled | boolean | false | Native disabled: unfocusable, skipped by the arrow keys. |
transition | ViewTransitionClass | – | Forwarded to the Item's <ViewTransition default>; without it no boundary is rendered. Fires only on the commit that settles an action or rolls it back, never on the check itself. |
className | string | ((state: RadioGroupItemState) => string | undefined) | – | A class name, or a function of the Item's state. |
style | CSSProperties | ((state: RadioGroupItemState) => CSSProperties | undefined) | – | Inline style, or a function of the Item's state. |
render | ReactElement | ((props, state: RadioGroupItemState) => ReactNode) | – | Replace the default <input>. Must keep an <input>; the library sets type="radio". |
RadioGroupItemState: { value, checked, pending, disabled }. checked is optimistic, pending is true on the radio the user is waiting on, and disabled includes Root disabled. The Item owns type, name, value, checked, defaultChecked, disabled, form, required and onChange; id and other input props pass through.
| Attribute | On | Pseudo-class | Meaning |
|---|---|---|---|
data-pending | Root | – | valueChangeAction is in flight. |
data-pending | Item | – | The radio the in-flight dispatch is checking (the optimistic one). |
data-checked | Item | :checked | Optimistic checked state. Identical, because the DOM holds the optimistic value. |
data-disabled | Root, Item | :disabled | Disabled by prop; on an Item, by its own prop or Root disabled. |
| – | Item | :required, :user-invalid, :focus-visible | Platform only; no attribute. |
| – | Item | :indeterminate | Every radio of a group with nothing checked (platform). |
.radio:checked::before { scale: 1; } /* flips on click, reverts on rejection */
.radio[data-pending] { animation: pulse 1s infinite alternate; }
.option:has(.radio:checked) { font-weight: 600; }
.group[data-pending] { cursor: progress; }Everything here is the browser's; the library installs no key handler.
| Key | Behaviour | Dispatches |
|---|---|---|
| Tab / Shift+Tab | Enters the group on the checked radio (the first, with nothing checked) and leaves it on the next press. | no |
| Space | Checks the focused radio. | yes |
| ArrowDown / ArrowRight | Focuses and checks the next enabled radio, wrapping (not in Safari). Mirrored under dir="rtl" (not in Safari). | yes, once per press |
| ArrowUp / ArrowLeft | Previous enabled radio, wrapping (not in Safari). Mirrored under RTL (not in Safari). | yes, once per press |
| Enter | Submits the form (implicit submission). | no |
orientation prop: native radios honour all four arrows whatever the layout.aria-live region if a slow action may fail.aria-checked (forbidden on native inputs) and no tabindex (the browser does the roving)."use client";
import { useState } from "react";
import { RadioGroup } from "@funstack/ui/radio-group";
const alignments = [
{ value: "right", label: "يمين (right)" },
{ value: "center", label: "وسط (center)" },
{ value: "left", label: "يسار (left)" },
];
// Inside dir="rtl" the browser mirrors the arrow keys: ArrowLeft moves to the next radio, which
// is visually to the left (Safari does not mirror). The library has no keyboard code of its own.
// Controlled with local state: the setState runs in the action's synchronous part, so it is a
// transition update.
export function RadioGroupRtl() {
const [value, setValue] = useState<string | null>("right");
return (
<>
<div dir="rtl">
<RadioGroup.Root
className="demo-radio-group demo-radio-inline"
value={value}
valueChangeAction={(next) => {
setValue(next);
}}
>
<RadioGroup.Label>المحاذاة (alignment)</RadioGroup.Label>
{alignments.map((option) => (
<label key={option.value} className="demo-field">
<RadioGroup.Item value={option.value} className="demo-radio" />
{option.label}
</label>
))}
</RadioGroup.Root>
</div>
<output className="demo-readout">
value: <code>{JSON.stringify(value)}</code>
</output>
</>
);
}/* RadioGroup: the Root is a <fieldset>, the Label its <legend>, each Item the visible radio
(appearance: none + ::before). `:checked` and `[data-checked]` agree: the DOM `checked` is the
optimistic value. */
.demo-radio-group {
display: grid;
gap: 0.4rem;
justify-items: start;
margin: 0;
padding: 0.5rem 1rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
}
.demo-radio-group legend {
padding-inline: 0.3rem;
font-weight: 600;
color: var(--color-text);
}
.demo-radio-group[data-pending] {
cursor: progress;
}
.demo-radio-inline {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
}
.demo-radio {
appearance: none;
flex: none;
margin: 0;
inline-size: 1.25em;
block-size: 1.25em;
border: 2px solid var(--color-text-secondary);
border-radius: 50%;
display: inline-grid;
place-content: center;
cursor: inherit;
transition: border-color 120ms;
}
.demo-radio::before {
content: "";
inline-size: 0.6em;
block-size: 0.6em;
border-radius: 50%;
background: var(--color-primary);
scale: 0;
transition: scale 120ms;
}
.demo-radio:checked {
border-color: var(--color-primary);
}
/* Optimistic: flips on click, reverts on rejection or veto. */
.demo-radio:checked::before {
scale: 1;
}
.demo-radio:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.demo-radio:user-invalid {
border-color: #dc2626;
}
.demo-field:has(.demo-radio:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
/* The row spinner shows only next to the radio the user is waiting on. */
.demo-radio-spinner {
display: none;
}
.demo-field:has(.demo-radio[data-pending]) .demo-radio-spinner {
display: inline-block;
}
.demo-radio-saving {
font-weight: normal;
font-size: 0.85em;
color: var(--color-text-secondary);
}
@media (prefers-reduced-motion: reduce) {
.demo-radio,
.demo-radio::before {
transition: none;
}
}valueChangeAction("dark", { reason: "activate" }) in the Root's transition. The Root and the Dark radio get data-pending.checked back to the old radio, and the error reaches the nearest Error Boundary.(next) => { void save(next); }), or leave the value to the form submit."use client";
import { startTransition, useState } from "react";
import { useFormStatus } from "react-dom";
import { RadioGroup } from "@funstack/ui/radio-group";
import type { RadioGroupValueChangeDetails } from "@funstack/ui/radio-group";
import { fakeSave } from "./fake-api.js";
const sizes = [
{ value: "s", label: "Small" },
{ value: "m", label: "Medium" },
{ value: "l", label: "Large" },
{ value: "xl", label: "Extra large (sold out)" },
];
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" className="demo-button demo-primary">
{pending && <span className="demo-spinner" aria-hidden />}
Order
</button>
);
}
// The radios post `size=…` with the form (a real `name`), and `required` blocks the submit until
// one is checked. After the form action succeeds React resets the form; the ordered size is the
// new `defaultValue`, so the reset returns to it. The Reset button returns to the latest default
// too. `valueChangeAction` here only records what the group was told: user changes arrive with
// reason "activate", resets with reason "platform" (`null` when the reset empties the group).
export function RadioGroupForm() {
const [ordered, setOrdered] = useState<string | null>(null);
const [log, setLog] = useState<string[]>([]);
const record = (next: string | null, { reason, event }: RadioGroupValueChangeDetails) => {
const entry = `${JSON.stringify(next)} (${reason}, ${event?.type ?? "no event"})`;
setLog((prev) => [entry, ...prev].slice(0, 4));
};
return (
<form
className="demo-stack"
action={async (formData) => {
await fakeSave("the order");
const size = formData.get("size");
startTransition(() => setOrdered(typeof size === "string" ? size : null));
}}
>
<RadioGroup.Root
className="demo-radio-group"
name="size"
required
defaultValue={ordered}
valueChangeAction={record}
>
<RadioGroup.Label>T-shirt size</RadioGroup.Label>
{sizes.map((size) => (
<label key={size.value} className="demo-field">
<RadioGroup.Item
value={size.value}
className="demo-radio"
disabled={size.value === "xl"}
/>
{size.label}
</label>
))}
</RadioGroup.Root>
<div className="demo-row">
<SubmitButton />
<button type="reset" className="demo-button">
Reset
</button>
</div>
<output className="demo-readout">
ordered: <code>{JSON.stringify(ordered)}</code>
<br />
valueChangeAction calls, latest first: <code>{log.join(" · ") || "(none yet)"}</code>
</output>
</form>
);
}/* RadioGroup: the Root is a <fieldset>, the Label its <legend>, each Item the visible radio
(appearance: none + ::before). `:checked` and `[data-checked]` agree: the DOM `checked` is the
optimistic value. */
.demo-radio-group {
display: grid;
gap: 0.4rem;
justify-items: start;
margin: 0;
padding: 0.5rem 1rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
}
.demo-radio-group legend {
padding-inline: 0.3rem;
font-weight: 600;
color: var(--color-text);
}
.demo-radio-group[data-pending] {
cursor: progress;
}
.demo-radio-inline {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
}
.demo-radio {
appearance: none;
flex: none;
margin: 0;
inline-size: 1.25em;
block-size: 1.25em;
border: 2px solid var(--color-text-secondary);
border-radius: 50%;
display: inline-grid;
place-content: center;
cursor: inherit;
transition: border-color 120ms;
}
.demo-radio::before {
content: "";
inline-size: 0.6em;
block-size: 0.6em;
border-radius: 50%;
background: var(--color-primary);
scale: 0;
transition: scale 120ms;
}
.demo-radio:checked {
border-color: var(--color-primary);
}
/* Optimistic: flips on click, reverts on rejection or veto. */
.demo-radio:checked::before {
scale: 1;
}
.demo-radio:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.demo-radio:user-invalid {
border-color: #dc2626;
}
.demo-field:has(.demo-radio:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
/* The row spinner shows only next to the radio the user is waiting on. */
.demo-radio-spinner {
display: none;
}
.demo-field:has(.demo-radio[data-pending]) .demo-radio-spinner {
display: inline-block;
}
.demo-radio-saving {
font-weight: normal;
font-size: 0.85em;
color: var(--color-text-secondary);
}
@media (prefers-reduced-motion: reduce) {
.demo-radio,
.demo-radio::before {
transition: none;
}
}name the group posts the checked value; required blocks the submit until a radio is checked and every radio matches :user-invalid after the attempt.form.reset(), or React's automatic reset after the action succeeds) is one dispatch for the whole group, with reason: "platform" and the reset event. Uncontrolled, the target is the latest defaultValue (here the ordered size). Controlled, the mount-time value is offered to the parent, which adopts it or not. A disabled group is reset too, since the form's author reset it.// Autosave: a per-group action with data-pending, passed directly from a Server Component.
<RadioGroup.Root defaultValue={user.plan} valueChangeAction={setPlan}>...</RadioGroup.Root>
// Native submission: no action on the group; pending is the form's.
<form action={placeOrder}>
<RadioGroup.Root name="size" required>...</RadioGroup.Root>
<SubmitButton />
</form>The two channels are independent and may be combined. The group never reads useFormStatus. An action-less group is a working native radio group before hydration and with JavaScript off.