Radio lets users pick a single option from a small, mutually exclusive set.
The root owns the group state (value, defaultValue, onChange) and cascades
visual props (size, position, invalid) to every item.
Usage
import { Radio } from '@takeoff-ui/react-spar';
<Radio>
<Radio.Item value="...">
<Radio.Indicator />
<Radio.Label>
Label
{}
<span>Helper text</span>
</Radio.Label>
</Radio.Item>
</Radio>
Playground
function PlaygroundDemo() {
return (
<Radio aria-label="Cabin class" defaultValue="economy">
<Radio.Item value="economy">
<Radio.Indicator />
<Radio.Label>
Economy
<span className="text-text-base text-(length:--desktop-body-xs-size) leading-(--desktop-body-xs-line-height) font-normal">
Standard fare with carry-on baggage.
</span>
</Radio.Label>
</Radio.Item>
<Radio.Item value="business">
<Radio.Indicator />
<Radio.Label>
Business
<span className="text-text-base text-(length:--desktop-body-xs-size) leading-(--desktop-body-xs-line-height) font-normal">
Lie-flat seat, lounge access, priority boarding.
</span>
</Radio.Label>
</Radio.Item>
<Radio.Item value="first">
<Radio.Indicator />
<Radio.Label>
First
<span className="text-text-base text-(length:--desktop-body-xs-size) leading-(--desktop-body-xs-line-height) font-normal">
Private suite and chef-curated dining.
</span>
</Radio.Label>
</Radio.Item>
</Radio>
);
}
render(<PlaygroundDemo />);
Sizes
function SizesDemo() {
return (
<div className="grid gap-6">
<Radio aria-label="Small" size="small" defaultValue="aisle">
<Radio.Item value="aisle">
<Radio.Indicator />
<Radio.Label>Aisle</Radio.Label>
</Radio.Item>
<Radio.Item value="window">
<Radio.Indicator />
<Radio.Label>Window</Radio.Label>
</Radio.Item>
</Radio>
<Radio aria-label="Base" defaultValue="aisle">
<Radio.Item value="aisle">
<Radio.Indicator />
<Radio.Label>Aisle</Radio.Label>
</Radio.Item>
<Radio.Item value="window">
<Radio.Indicator />
<Radio.Label>Window</Radio.Label>
</Radio.Item>
</Radio>
<Radio aria-label="Large" size="large" defaultValue="aisle">
<Radio.Item value="aisle">
<Radio.Indicator />
<Radio.Label>Aisle</Radio.Label>
</Radio.Item>
<Radio.Item value="window">
<Radio.Indicator />
<Radio.Label>Window</Radio.Label>
</Radio.Item>
</Radio>
</div>
);
}
render(<SizesDemo />);
Layout
Switch the group to orientation="horizontal" for inline choices, opt into
spread to stretch items along the axis, or flip the indicator to the trailing
edge with position="right". Per-item position overrides the group default.
function LayoutDemo() {
return (
<div className="grid gap-6">
<Radio aria-label="Trip type" orientation="horizontal" spread defaultValue="round">
<Radio.Item value="one">
<Radio.Indicator />
<Radio.Label>One-way</Radio.Label>
</Radio.Item>
<Radio.Item value="round">
<Radio.Indicator />
<Radio.Label>Round trip</Radio.Label>
</Radio.Item>
<Radio.Item value="multi">
<Radio.Indicator />
<Radio.Label>Multi-city</Radio.Label>
</Radio.Item>
</Radio>
<Radio aria-label="Indicator on the trailing edge" position="right" defaultValue="email">
<Radio.Item value="email">
<Radio.Indicator />
<Radio.Label>
Email
<span className="text-text-base text-(length:--desktop-body-xs-size) leading-(--desktop-body-xs-line-height) font-normal">
Booking confirmations and itinerary changes.
</span>
</Radio.Label>
</Radio.Item>
<Radio.Item value="sms">
<Radio.Indicator />
<Radio.Label>
SMS
<span className="text-text-base text-(length:--desktop-body-xs-size) leading-(--desktop-body-xs-line-height) font-normal">
Gate changes and boarding reminders.
</span>
</Radio.Label>
</Radio.Item>
</Radio>
</div>
);
}
render(<LayoutDemo />);
Controlled
function ControlledRadioDemo() {
const [value, setValue] = useState('return');
return (
<div className="grid gap-3">
<Badge className="w-fit" variant="neutral" appearance="outlined">
Selected: {value}
</Badge>
<Radio aria-label="Refund preference" value={value} onChange={setValue}>
<Radio.Item value="return">
<Radio.Indicator />
<Radio.Label>
Refund to original payment
<span className="text-text-base text-(length:--desktop-body-xs-size) leading-(--desktop-body-xs-line-height) font-normal">
Settles in 5–10 business days.
</span>
</Radio.Label>
</Radio.Item>
<Radio.Item value="voucher">
<Radio.Indicator />
<Radio.Label>
Travel voucher
<span className="text-text-base text-(length:--desktop-body-xs-size) leading-(--desktop-body-xs-line-height) font-normal">
Includes a 10% bonus, valid for 12 months.
</span>
</Radio.Label>
</Radio.Item>
</Radio>
</div>
);
}
render(<ControlledRadioDemo />);
Disabled Item
function DisabledDemo() {
return (
<Radio aria-label="Ancillary services" defaultValue="lounge">
<Radio.Item value="lounge">
<Radio.Indicator />
<Radio.Label>
Lounge access
<span className="text-text-base text-(length:--desktop-body-xs-size) leading-(--desktop-body-xs-line-height) font-normal">
Available at IST and select hubs.
</span>
</Radio.Label>
</Radio.Item>
<Radio.Item value="upgrade" disabled>
<Radio.Indicator />
<Radio.Label>
Upgrade to Business (sold out)
<span className="text-text-base text-(length:--desktop-body-xs-size) leading-(--desktop-body-xs-line-height) font-normal">
No seats available on this flight.
</span>
</Radio.Label>
</Radio.Item>
</Radio>
);
}
render(<DisabledDemo />);
Invalid
function InvalidDemo() {
return (
<Radio aria-label="Passport type" invalid required>
<Radio.Item value="standard">
<Radio.Indicator />
<Radio.Label>Standard passport</Radio.Label>
</Radio.Item>
<Radio.Item value="diplomatic">
<Radio.Indicator />
<Radio.Label>Diplomatic passport</Radio.Label>
</Radio.Item>
</Radio>
);
}
render(<InvalidDemo />);
Item Render-Prop
Radio.Item accepts a function child that receives the per-item state from Spar
(isChecked, disabled, isFocused, select), so each item can react to its
own selection without reading context manually.
function RenderPropDemo() {
return (
<Radio aria-label="Seat row preference" defaultValue="exit">
<Radio.Item value="front">
{({ isChecked }) => (
<>
<Radio.Indicator />
<Radio.Label>
Front of cabin
<span className="text-text-base text-(length:--desktop-body-xs-size) leading-(--desktop-body-xs-line-height) font-normal">
{isChecked ? 'Selected — boards first.' : 'Quieter, near the galley.'}
</span>
</Radio.Label>
</>
)}
</Radio.Item>
<Radio.Item value="exit">
{({ isChecked }) => (
<>
<Radio.Indicator />
<Radio.Label>
Exit row
<span className="text-text-base text-(length:--desktop-body-xs-size) leading-(--desktop-body-xs-line-height) font-normal">
{isChecked ? 'Selected — extra legroom, eligibility required.' : 'Extra legroom, eligibility required.'}
</span>
</Radio.Label>
</>
)}
</Radio.Item>
</Radio>
);
}
render(<RenderPropDemo />);
Accessibility & Keyboard
- The root renders
role="radiogroup"; each Radio.Item is a
<label role="radio"> wrapping a hidden <input type="radio"> so the group
participates in native form submission.
- Provide a group label via
aria-label or aria-labelledby. Radio.Label
labels the individual item — compose helper text or richer markup directly
inside it.
Radio.Indicator is decorative (aria-hidden); the checked state is surfaced
to assistive tech via aria-checked on the item.
| Key | Behavior |
|---|
| Tab | Move focus into the group (lands on the checked item, or the first). |
| ↓ / → | Move focus to the next item and select it. |
| ↑ / ← | Move focus to the previous item and select it. |
| Space | Select the focused item. |
Disabled items skip both pointer and keyboard activation. Set selectOnFocus to
false if you want focus and selection to decouple, and autoFocus to move
focus into the group on mount.
API Reference
Radio
See Spar Radio docs
for primitive behavior.
Props
| Name | Type | Default | Description |
|---|
| children | React.ReactNode | - | Radio.Item elements rendered inside the radiogroup container. |
| size | RadioSize | 'base' | Size scale. Cascades to descendant Radio.Items via context. |
| invalid | boolean | false | Marks the group as visually invalid. Cascades to descendant Radio.Items. When nested inside a <Field> with invalid, this is inherited automatically; a direct prop on <Radio> always wins. Maps to Spar's invalid for ARIA wiring (aria-invalid). |
| spread | boolean | false | Stretches each item to share the group axis equally. Layout intent only — styling is recipe-side via [data-spread] > [data-slot="root"]. |
| position | RadioPosition | 'left' | Default placement of an item's indicator relative to its label. Cascades to descendant Radio.Items; per-item position overrides the group value. |
| classNames | Partial<Record<"root", string>> | - | Per-slot class name overrides. |
| slotProps | Partial<Record<"root", React.HTMLAttributes<HTMLElement>>> | - | Per-slot HTML attribute overrides. |
| className | string | - | Appends custom classes to the root slot of this part. |
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-size | Always | Reflects the resolved size prop so theme recipes can scope size variants. |
| data-position | Always | Reflects the resolved indicator position (left / right). |
| data-invalid | When invalid is true, or inherited from a parent <Field invalid>. | Theme hook for the invalid state. Emitted by Spar on the radiogroup root only; items style themselves through the ancestor selector .tk-radio[data-invalid] .tk-radio-item. |
| data-spread | When spread is true. | Layout hook used by recipes to stretch each item along the group axis. |
| data-orientation | Always | Reflects the resolved orientation (vertical / horizontal). Emitted by Spar. |
| data-disabled | When disabled is true. | Theme hook for the disabled group. Emitted by Spar. |
| data-required | When required is true. | Theme hook surfacing the required state. Emitted by Spar. |
Radio.Item
See Spar Radio docs
for primitive behavior.
Props
| Name | Type | Default | Description |
|---|
| children | React.ReactNode | - | Radio.Indicator and Radio.Label (compose helper text or richer markup inside the label), or a render function exposing per-item Spar state. |
| position | RadioPosition | inherited from group | Override the group-level position for this item only. |
| classNames | Partial<Record<"root", string>> | - | Per-slot class name overrides. |
| slotProps | Partial<Record<"root", React.HTMLAttributes<HTMLElement>>> | - | Per-slot HTML attribute overrides. |
| className | string | - | Appends custom classes to the root slot of this part. |
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-size | Always | Reflects the resolved size prop so theme recipes can scope size variants. |
| data-position | Always | Reflects the resolved indicator position (left / right). |
| data-state="checked" | When the item matches the group value. | Spar checked-state hook. Drives the indicator fill. |
| data-state="unchecked" | When the item does not match the group value. | Spar unchecked-state hook. |
| data-disabled | When the item disabled is true, or the group is disabled. | Theme hook for the disabled item state. Emitted by Spar. |
Radio.Indicator
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
| data-slot="icon" | Always | Internal decorative fill element. Customize via slotProps.icon / classNames.icon. |
Radio.Label
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="root" | Always | Stable selector for wrapper styling on the root slot. |
Type Definitions
| Name | Definition |
|---|
| RadioSize | 'small' | 'base' | 'large' |
| RadioPosition | 'left' | 'right' |
| RadioIndicatorSlot | 'root' | 'icon' |