Switch lets users turn a single setting on or off, especially for preferences,
permissions, and form options. The root owns visual state (size, variant,
plus inherited invalid / disabled / required / optional / readOnly),
Switch.Indicator is the track + sliding thumb. Wrap Switch in a Field to
attach a label, description, or error message — the field-level state cascades
into the switch automatically.
Usage
import { Field, Switch } from '@takeoff-ui/react-spar';
<Field>
<Field.Label />
<Switch>
<Switch.Indicator />
</Switch>
<Field.Description />
<Field.ErrorMessage />
</Field>
Playground
function PlaygroundDemo() {
return (
<Field className="max-w-90">
<div className="flex w-full items-center justify-between">
<div>
<Field.Label>Flight status alerts</Field.Label>
<Field.Description>We will let you know about delays, gate changes, and cancellations.</Field.Description>
</div>
<Switch defaultChecked>
<Switch.Indicator />
</Switch>
</div>
</Field>
);
}
render(<PlaygroundDemo />);
Using with Field
Field is the generic ARIA wrapper. Setting invalid, disabled, required,
optional, or readOnly on Field cascades into the nested Switch, and
Field.Label, Field.Description, and Field.ErrorMessage are wired to the
switch through shared IDs.
function FieldWithSwitchDemo() {
return (
<div className="grid w-full max-w-90 gap-6">
<Field required>
<div className="flex w-full items-center justify-between">
<Field.Label>Email alerts</Field.Label>
<Switch defaultChecked>
<Switch.Indicator />
</Switch>
</div>
</Field>
<Field>
<div className="flex w-full items-center justify-between">
<div>
<Field.Label>SMS alerts</Field.Label>
<Field.Description>Standard messaging rates may apply.</Field.Description>
</div>
<Switch>
<Switch.Indicator />
</Switch>
</div>
</Field>
<Field invalid>
<div className="flex w-full items-center justify-between">
<div>
<Field.Label>Push notifications</Field.Label>
<Field.ErrorMessage>Enable browser notifications to turn this on.</Field.ErrorMessage>
</div>
<Switch>
<Switch.Indicator />
</Switch>
</div>
</Field>
<Field disabled>
<div className="flex w-full items-center justify-between">
<Field.Label>Disabled setting</Field.Label>
<Switch>
<Switch.Indicator />
</Switch>
</div>
</Field>
<Field readOnly>
<div className="flex w-full items-center justify-between">
<Field.Label>Read-only setting</Field.Label>
<Switch defaultChecked>
<Switch.Indicator />
</Switch>
</div>
</Field>
</div>
);
}
render(<FieldWithSwitchDemo />);
Controlled
function ControlledSwitchDemo() {
const [enabled, setEnabled] = useState(true);
return (
<div className="grid w-full max-w-90 gap-3">
<Badge className="w-fit" variant="neutral" appearance="outlined">
Notifications {enabled ? 'enabled' : 'paused'}
</Badge>
<Field>
<div className="flex w-full items-center justify-between">
<Field.Label>Flight status notifications</Field.Label>
<Switch checked={enabled} onChange={setEnabled}>
<Switch.Indicator />
</Switch>
</div>
</Field>
</div>
);
}
render(<ControlledSwitchDemo />);
Sizes
function SizeDemo() {
return (
<div className="flex flex-wrap items-center gap-3">
<Switch aria-label="Extra small switch" size="xsmall">
<Switch.Indicator />
</Switch>
<Switch aria-label="Small switch" size="small">
<Switch.Indicator />
</Switch>
<Switch aria-label="Base switch" size="base" defaultChecked>
<Switch.Indicator />
</Switch>
<Switch aria-label="Large switch" size="large" defaultChecked>
<Switch.Indicator />
</Switch>
<Switch aria-label="Extra large switch" size="xlarge" defaultChecked>
<Switch.Indicator />
</Switch>
</div>
);
}
render(<SizeDemo />);
Variants
function VariantDemo() {
return (
<div className="grid w-full max-w-90 gap-3">
<Field>
<div className="flex w-full items-center justify-between">
<Field.Label>Seat alerts</Field.Label>
<Switch defaultChecked variant="info">
<Switch.Indicator />
</Switch>
</div>
</Field>
<Field>
<div className="flex w-full items-center justify-between">
<Field.Label>Auto check-in</Field.Label>
<Switch defaultChecked variant="success">
<Switch.Indicator />
</Switch>
</div>
</Field>
</div>
);
}
render(<VariantDemo />);
Custom Indicator
Switch.Indicator accepts function children that receive the current checked
/ disabled / readOnly state, so you can render different content per state.
Passing a ReactNode instead replaces the default thumb slot entirely — the
consumer becomes responsible for any data-slot="thumb" wrapper they need for
theming.
function CustomIndicatorDemo() {
const SliderThumb = ({ active }) => (
<span
aria-hidden="true"
className="inline-flex h-full w-full items-center justify-center whitespace-nowrap transition-transform duration-200"
>
{active ? <CheckIconOutlinedRounded width={20} height={20} /> : <BlockIconOutlinedRounded width={20} height={20} />}
</span>
);
return (
<Field className="max-w-90">
<div className="flex w-full items-center justify-between">
<Field.Label>Auto check-in</Field.Label>
<Switch defaultChecked variant="success">
<Switch.Indicator>
{({ checked }) => (
<span data-slot="thumb" className="tk-toggle-thumb inline-flex items-center justify-center">
<SliderThumb active={checked} />
</span>
)}
</Switch.Indicator>
</Switch>
</div>
</Field>
);
}
render(<CustomIndicatorDemo />);
Render Prop
function RenderPropDemo() {
return (
<div>
<Field>
<Switch defaultChecked>
{({ checked }) => (
<>
<Field.Label>{checked ? 'Operational alerts active' : 'Operational alerts paused'}</Field.Label>
<Switch.Indicator />
</>
)}
</Switch>
</Field>
</div>
);
}
render(<RenderPropDemo />);
When name is set, Spar renders a synchronized hidden checkbox input for native
form submission.
function SwitchFormDemo() {
const [saved, setSaved] = useState('Not submitted');
return (
<form
className="grid w-full max-w-90 gap-3"
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
setSaved(
[data.get('email') ? 'Email alerts' : null, data.get('sms') ? 'SMS alerts' : null].filter(Boolean).join(', ') || 'No alerts',
);
}}
>
<Field>
<div className="flex w-full items-center justify-between">
<Field.Label>Email alerts</Field.Label>
<Switch name="email" value="enabled" defaultChecked>
<Switch.Indicator />
</Switch>
</div>
</Field>
<Field>
<div className="flex w-full items-center justify-between">
<Field.Label>SMS alerts</Field.Label>
<Switch name="sms" value="enabled">
<Switch.Indicator />
</Switch>
</div>
</Field>
<div className="flex flex-wrap items-center justify-end gap-3">
<Button type="submit">Save</Button>
<Badge className="w-fit" variant="neutral" appearance="outlined">
{saved}
</Badge>
</div>
</form>
);
}
render(<SwitchFormDemo />);
Accessibility
Field.Label, Field.Description, and Field.ErrorMessage are wired to the
control via stable IDs derived from the Field root (aria-labelledby,
aria-describedby, aria-invalid).
Switch renders a focusable control with role="switch" and reflects its
value through aria-checked.
- When the switch has no visible label (for example a size sampler), pass
aria-label directly on <Switch>.
- Disabled switches are removed from the tab order. Read-only switches remain
focusable and do not change value.
- When
name is provided, Spar renders a synchronized hidden checkbox input for
native form submission. name is the form field name, not the accessible
label.
| Key | Behavior |
|---|
| Enter / Space | Toggle the focused switch. |
| Tab | Move focus to the next tabbable UI. |
API Reference
Switch
See
Spar Switch docs
for primitive behavior.
Props
| Name | Type | Default | Description |
|---|
| children | React.ReactNode | ((state: SwitchRenderProps) => React.ReactNode) | - | Compound children for switch anatomy, or a render function exposing Spar state. |
| size | SwitchSize | 'base' | Size scale. |
| variant | SwitchVariant | 'info' | Color variant used while checked. |
| invalid | boolean | false | Marks the switch as visually invalid. Inherited from <Field> automatically; pass this prop only to override. |
| classNames | Partial<Record<SwitchSlot, string>> | - | Per-slot class name overrides. |
| slotProps | Partial<Record<SwitchSlot, 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 the root slot. |
| data-size | Always | Reflects the resolved size prop so theme recipes can scope size variants. |
| data-variant | Always | Reflects the resolved variant prop so theme recipes can scope color variants. |
| data-state="checked" | When checked. | Spar checked-state hook. |
| data-state="unchecked" | When unchecked. | Spar unchecked-state hook. |
| data-disabled | disabled is true. | Spar disabled-state hook. |
| data-readonly | readOnly is true. | Spar read-only-state hook. |
| data-required | required is true. | Spar required-state hook. |
| data-invalid | invalid is true. | Marks invalid visual state for theme recipes. |
Switch.Indicator
See
Spar Switch docs
for primitive behavior.
Data attributes
| Attribute | Applied when | Purpose |
|---|
| data-slot="indicator" | Always | Stable selector for the indicator slot. |
| data-slot="thumb" | Always | Stable selector for the thumb slot. |
Type Definitions
| Name | Definition |
|---|
| SwitchRenderProps | { checked: boolean; setChecked: (checked: boolean) => void; disabled: boolean; readOnly: boolean; required: boolean; invalid: boolean; isFocused: boolean; isHovered: boolean; isPressed: boolean } |
| SwitchSize | 'xlarge' | 'large' | 'base' | 'small' | 'xsmall' |
| SwitchVariant | 'info' | 'success' |
| SwitchSlot | 'root' | 'indicator' | 'thumb' |
| SwitchIndicatorRenderProps | { checked: boolean; disabled: boolean; readOnly: boolean } |