Radio
A single-select control. The user picks exactly one option with the pointer or the keyboard; the selected radio carries an accent border and a dot that scales in, on a true-circle box that stays round at any radius setting. A real form control — it forwards name / form / required / readOnly and can flag an invalid choice.
Basic
Compose Radio.Group (which owns the value) with one Radio.Field per option — each pairing a Radio box with its Radio.Label, the same first-class label idiom as Checkbox. The field wires the label to the box (clicking the label selects it, the label is the accessible name) and holds the box↔label gap; one size on the group sizes every row. Name the group with aria-label (or aria-labelledby), and drive it with value / onValueChange or an uncontrolled defaultValue.
"use client";
import { Radio } from "@stridge/noctis";
import { useState } from "react";
const OPTIONS = [
{ value: "comfortable", label: "Comfortable" },
{ value: "compact", label: "Compact" },
{ value: "spacious", label: "Spacious" },
];
export default function RadioBasic() {
const [value, setValue] = useState(OPTIONS[0].value);
// One `size` on the group sizes every row; each row is a `Radio.Field` pairing the box with its
// `Radio.Label` (the field wires the label to the box and holds the gap), mirroring `Checkbox.Field`.
return (
<Radio.Group aria-label="Density" size="sm" value={value} onValueChange={setValue}>
{OPTIONS.map((option) => (
<Radio.Field key={option.value}>
<Radio.Root value={option.value} />
<Radio.Label>{option.label}</Radio.Label>
</Radio.Field>
))}
</Radio.Group>
);
}
Orientation
A group stacks vertically by default; orientation="horizontal" lays it out in a wrapping row. Arrow keys navigate both axes, and the row mirrors under RTL by construction.
"use client";
import { Radio } from "@stridge/noctis";
import { useState } from "react";
const OPTIONS = [
{ value: "list", label: "List" },
{ value: "board", label: "Board" },
{ value: "calendar", label: "Calendar" },
];
// `orientation="horizontal"` lays the group out in a row (wrapping as needed); arrows navigate both axes
// and the row mirrors under RTL by construction.
export default function RadioOrientation() {
const [value, setValue] = useState("board");
return (
<Radio.Group aria-label="View" orientation="horizontal" value={value} onValueChange={setValue}>
{OPTIONS.map((option) => (
<Radio.Field key={option.value}>
<Radio.Root value={option.value} />
<Radio.Label>{option.label}</Radio.Label>
</Radio.Field>
))}
</Radio.Group>
);
}
Sizes
size on the group sets the box edge length of every radio — sm for dense rows, md (the default), or lg for cards and touch. The accent dot re-points with it, so it stays proportional.
"use client";
import { Radio } from "@stridge/noctis";
const OPTIONS = [
{ value: "weekly", label: "Weekly" },
{ value: "monthly", label: "Monthly" },
];
// One `size` on the group sizes the boxes; the `Radio.Label` text tracks it automatically (mini /
// small / regular for sm / md / lg, the same scale Switch uses) — no per-row text class needed.
const SIZES = ["sm", "md", "lg"] as const;
export default function RadioSizes() {
return (
<div className="flex flex-col gap-6">
{SIZES.map((size) => (
<Radio.Group key={size} aria-label={`${size} cadence`} size={size} defaultValue="weekly">
{OPTIONS.map((option) => (
<Radio.Field key={option.value}>
<Radio.Root value={option.value} />
<Radio.Label>{option.label}</Radio.Label>
</Radio.Field>
))}
</Radio.Group>
))}
</div>
);
}
Descriptions
Add a Radio.Description per option for helper text, wired to its radio with aria-describedby. Keep the description a sibling of the Radio.Field so it isn't folded into the accessible name.
"use client";
import { Radio } from "@stridge/noctis";
import { useState } from "react";
const OPTIONS = [
{ value: "starter", label: "Starter", description: "1 project, community support." },
{ value: "pro", label: "Pro", description: "Unlimited projects and history." },
{ value: "team", label: "Team", description: "Shared workspaces and roles." },
];
// A `Radio.Description` adds helper text per option, wired to its radio with `aria-describedby` (the
// description is a sibling of the label so it isn't folded into the accessible name).
export default function RadioWithDescription() {
const [value, setValue] = useState("pro");
return (
<Radio.Group aria-label="Plan" value={value} onValueChange={setValue}>
{OPTIONS.map((option) => (
<div key={option.value} className="flex flex-col">
<Radio.Field className="font-medium">
<Radio.Root value={option.value} aria-describedby={`${option.value}-desc`} />
<Radio.Label>{option.label}</Radio.Label>
</Radio.Field>
<Radio.Description id={`${option.value}-desc`} className="ms-6">
{option.description}
</Radio.Description>
</div>
))}
</Radio.Group>
);
}
Validation
Mark the group required and flag it invalid to surface a validation error: the danger border paints on every radio and the error message is wired through aria-describedby. Validation fires at submit, not on blur — colour is never the only signal.
"use client";
import { Button, Radio } from "@stridge/noctis";
import { useState } from "react";
const OPTIONS = [
{ value: "email", label: "Email" },
{ value: "sms", label: "SMS" },
{ value: "push", label: "Push" },
];
// `required` applies to the group (Geist), and validation fires at submit. Submit with nothing chosen to
// see the invalid state: the danger border paints on every radio and the error text is wired via
// `aria-describedby`. Reset returns to the empty state so the error is re-testable (a radio can't be
// unselected on its own).
export default function RadioInvalid() {
const [value, setValue] = useState<string | undefined>(undefined);
const [submitted, setSubmitted] = useState(false);
const invalid = submitted && !value;
return (
<form
className="flex flex-col items-start gap-3"
onSubmit={(event) => {
event.preventDefault();
setSubmitted(true);
}}
>
<Radio.Group
aria-label="Notification channel"
required
invalid={invalid}
value={value}
onValueChange={setValue}
aria-describedby={invalid ? "channel-error" : undefined}
>
{OPTIONS.map((option) => (
<Radio.Field key={option.value}>
<Radio.Root value={option.value} />
<Radio.Label>{option.label}</Radio.Label>
</Radio.Field>
))}
</Radio.Group>
{invalid ? (
<p id="channel-error" className="text-small text-danger">
Choose a channel to continue.
</p>
) : null}
<div className="flex gap-2">
<Button type="submit" size="sm">
Continue
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setValue(undefined);
setSubmitted(false);
}}
>
Reset
</Button>
</div>
</form>
);
}
Cards
Radio.Card is a selectable card: a sharp bordered target wrapping a radio, so the whole card toggles. Selecting it tints the edge to the accent; hover is a neutral surface shift and the focus ring moves to the card. The control sits at the leading edge by default, or the trailing edge via controlPosition.
"use client";
import { Radio } from "@stridge/noctis";
import { useState } from "react";
const PLANS = [
{ value: "starter", title: "Starter", note: "1 project, community support." },
{ value: "pro", title: "Pro", note: "Unlimited projects and history." },
{ value: "team", title: "Team", note: "Shared workspaces and roles." },
];
// `Radio.Card` is a selectable card: the whole sharp bordered chip is the target, the edge tints to the
// accent when selected, and hover is a neutral surface shift. The group lays the cards out in a wrapping
// row.
export default function RadioCards() {
const [value, setValue] = useState("pro");
return (
<Radio.Group
aria-label="Plan"
orientation="horizontal"
value={value}
onValueChange={setValue}
className="max-w-lg w-full"
>
{PLANS.map((plan) => (
<Radio.Card key={plan.value} value={plan.value} className="flex-1 basis-44">
<span className="flex flex-col gap-1">
<span className="font-medium text-foreground">{plan.title}</span>
<Radio.Description>{plan.note}</Radio.Description>
</span>
</Radio.Card>
))}
</Radio.Group>
);
}
Disabled
Set disabled on a single Radio to lock one option inside an otherwise enabled group — arrow navigation skips it. Set disabled on the whole Radio.Group to dim and lock every option at once.
"use client";
import { Radio } from "@stridge/noctis";
// A single disabled option inside an otherwise enabled group: arrow navigation skips it, and it can't be
// selected, but the rest of the group works normally.
const OPTIONS = [
{ value: "card", label: "Credit card", disabled: false },
{ value: "transfer", label: "Bank transfer", disabled: true },
{ value: "invoice", label: "Invoice", disabled: false },
];
export default function RadioDisabled() {
return (
<Radio.Group aria-label="Payment method" defaultValue="card">
{OPTIONS.map((option) => (
// `disabled` on the field dims and locks the label; on the box it stops selection — together
// the whole row reads as disabled.
<Radio.Field key={option.value} disabled={option.disabled}>
<Radio.Root value={option.value} disabled={option.disabled} />
<Radio.Label>{option.label}</Radio.Label>
</Radio.Field>
))}
</Radio.Group>
);
}
Standalone
A radio can be named by aria-label instead of visible text — an icon-style picker where the option is self-evident. It is still a group, so arrows and single-select apply.
"use client";
import { Radio } from "@stridge/noctis";
import { useState } from "react";
// Standalone, label-less radios: each is named by `aria-label` rather than visible text — for an
// icon-style picker where the option is self-evident. Still a group, so arrows and single-select apply.
export default function RadioStandalone() {
const [value, setValue] = useState("system");
return (
<Radio.Group aria-label="Theme" orientation="horizontal" value={value} onValueChange={setValue}>
<Radio.Root value="light" aria-label="Light theme" />
<Radio.Root value="dark" aria-label="Dark theme" />
<Radio.Root value="system" aria-label="System theme" />
</Radio.Group>
);
}
Keyboard
| Key | Action |
|---|---|
| Tab | Move focus into the group, landing on the selected radio (or the first); Tab again leaves the group. |
| ← / → / ↑ / ↓ | Move to the next / previous radio and select it (selection follows focus), wrapping at the ends. Arrows mirror under RTL. |
| Space | Select the focused radio. |
A disabled option inside an enabled group is skipped by the arrows. A radio group is a single composite tab stop — Tab advances to the next field, not the next radio.
Accessibility
- Roles.
Radio.Groupis aradiogroup(named byaria-label/aria-labelledby/ a<legend>); eachRadiois aradio, named by itsRadio.Label(or anaria-labelfor a label-less radio).aria-orientationreflects theorientationprop. - Validation.
invalidsetsaria-invalidon the group; pair it with an error message wired byaria-describedby. The danger border is a reinforcement, never the only signal. - Read-only. A read-only radio stays focusable and announced — reflected visually but never removed from the accessibility tree.
- Descriptions.
Radio.Descriptionis associated to its radio witharia-describedby(the consumer sets the matchingid). - Motion. The dot scales / fades in on select; under
prefers-reduced-motion: reduceit appears instantly, meaning preserved.
Anatomy
Compose the control from its parts. Radio.Group is a Base UI RadioGroup, so selection, roving focus, arrow-key navigation, and the form surface (name / form / required / readOnly) come for free.
Radio.Group— the group; owns the selected value (value/onValueChange, or uncontrolleddefaultValue), the sharedsize, and theorientation. Forwardsname/form/required/readOnlyto Base UI; flag the value in error withinvalid; disable everything withdisabled.Radio— one radio box. Pass itsvalue(and optionalreadOnly/required); it renders the accent dot (Radio.Indicator) for itself when selected.Radio.Field— the labelled row pairing a box with its label (Base UI'sField.Root), mirroringCheckbox.Field. Inherits the group'ssizeunless it sets its own, and cascades it to the box.Radio.Label— the box's visible accessible name (Base UI'sField.Label); clicking it selects the box, and it mirrors the field'sdisabled/invalidstate. For a label-less radio (an icon picker), give theRadioanaria-labelinstead.Radio.Indicator— the accent dot; kept mounted so it scales in on select.Radiorenders it automatically.Radio.Description— muted helper text for a radio, wired byaria-describedby.Radio.Card— a selectable card target wrapping a radio, its label, and an optional description.
Every rendered part carries a data-slot (noctis-radio-group, noctis-radio, noctis-radio-indicator, noctis-radio-field, noctis-radio-label, noctis-radio-description, noctis-radio-card) for host-side styling — pair it with the Base UI radio state attributes (data-checked, data-unchecked, data-disabled, data-readonly, data-invalid) and the data-size / data-orientation axes.
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every radio in that region retunes — e.g. .dense { --noctis-radio-group-gap: 0.375rem; } tightens the stack beneath it. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; the Group forwards the Base UI RadioGroup props it owns selection through. Expand a row for the full type and description.