Switch
A toggle between two opposing on/off states. The thumb slides across the track when on and the track fills with the accent signal — the indigo reserved for active, checked controls.
Basic
Pair a Switch.Label with a Switch.Root inside a Switch.Field: the label becomes the accessible name, clicking it toggles the switch, and the whole row reads as one control. Switch.Root owns the checked state — drive it with checked / onCheckedChange, or omit them and pass defaultChecked to let it own its state.
"use client";
import { Switch } from "@stridge/noctis";
import { useState } from "react";
export default function SwitchBasic() {
const [checked, setChecked] = useState(true);
return (
<Switch.Field>
<Switch.Label>Notifications</Switch.Label>
<Switch.Root checked={checked} onCheckedChange={setChecked}>
<Switch.Thumb />
</Switch.Root>
</Switch.Field>
);
}
In a settings row
Switches are most at home in settings lists, where each row is a labelled, immediate-effect toggle. The label sits at the inline start and the switch at the inline end, mirroring under RTL by construction.
"use client";
import { Surface, Switch } from "@stridge/noctis";
import { useState } from "react";
const ROWS = [
{ key: "wifi", label: "Wi-Fi" },
{ key: "bluetooth", label: "Bluetooth" },
{ key: "airplane", label: "Airplane mode" },
] as const;
export default function SwitchSettingsRow() {
const [state, setState] = useState<Record<string, boolean>>({ wifi: true, bluetooth: false, airplane: false });
return (
<Surface bordered className="flex w-full max-w-sm flex-col gap-1 rounded-md p-2">
{ROWS.map(({ key, label }) => (
<Switch.Field key={key} size="sm" className="flex w-full justify-between px-2 py-1.5">
<Switch.Label>{label}</Switch.Label>
<Switch.Root
checked={state[key]}
onCheckedChange={(checked) => setState((prev) => ({ ...prev, [key]: checked }))}
>
<Switch.Thumb />
</Switch.Root>
</Switch.Field>
))}
</Surface>
);
}
Sizes
size sets the scale — sm for compact rows, md (the default), or lg. Setting it on Switch.Field scales the label text and cascades to the switch inside, so the label and control always read at the same weight; setting it on a standalone Switch.Root scales just the track and thumb.
"use client";
import { Switch } from "@stridge/noctis";
const SIZES = [
{ size: "sm", label: "Small" },
{ size: "md", label: "Medium" },
{ size: "lg", label: "Large" },
] as const;
export default function SwitchSizes() {
return (
<div className="flex items-center gap-8">
{SIZES.map(({ size, label }) => (
<Switch.Field key={size} size={size}>
<Switch.Label>{label}</Switch.Label>
<Switch.Root defaultChecked>
<Switch.Thumb />
</Switch.Root>
</Switch.Field>
))}
</div>
);
}
In a form
Give Switch.Root a name (and an optional value, defaulting to "on") and it participates in form submission through a hidden input. Add required to demand it be on before the form submits.
"use client";
import { Button, Switch } from "@stridge/noctis";
import { useState } from "react";
export default function SwitchForm() {
const [submitted, setSubmitted] = useState<string | null>(null);
return (
<form
className="flex w-full max-w-xs flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
setSubmitted(data.get("marketing") === "yes" ? "Subscribed" : "Not subscribed");
}}
>
<Switch.Field className="flex w-full justify-between">
<Switch.Label>Marketing emails</Switch.Label>
<Switch.Root name="marketing" value="yes" defaultChecked>
<Switch.Thumb />
</Switch.Root>
</Switch.Field>
<div className="flex items-center gap-3">
<Button type="submit" size="sm">
Save
</Button>
{submitted ? <output className="text-small text-muted">{submitted}</output> : null}
</div>
</form>
);
}
Validation
A required switch that has not been turned on reads its error through the danger border. Reflect the state with aria-invalid and point aria-describedby at the error message so assistive tech announces it.
"use client";
import { Button, Switch } from "@stridge/noctis";
import { useState } from "react";
export default function SwitchValidation() {
const [accepted, setAccepted] = useState(false);
const [submitted, setSubmitted] = useState(false);
const invalid = submitted && !accepted;
return (
<form
className="flex w-full max-w-xs flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
setSubmitted(true);
}}
>
<Switch.Field className="flex w-full justify-between">
<Switch.Label>I accept the terms</Switch.Label>
<Switch.Root
required
checked={accepted}
onCheckedChange={setAccepted}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? "terms-error" : undefined}
>
<Switch.Thumb />
</Switch.Root>
</Switch.Field>
{invalid ? (
<p id="terms-error" className="text-small text-danger">
You must accept the terms to continue.
</p>
) : null}
<Button type="submit" size="sm" className="self-start">
Continue
</Button>
</form>
);
}
Read-only
readOnly keeps the switch legible and focusable but not editable — it drops the pointer and the hover affordance while staying at full opacity, since the value is valid, just not yours to change.
"use client";
import { Switch } from "@stridge/noctis";
export default function SwitchReadOnly() {
return (
<Switch.Field>
<Switch.Label>Managed by your organization</Switch.Label>
<Switch.Root readOnly defaultChecked>
<Switch.Thumb />
</Switch.Root>
</Switch.Field>
);
}
Disabled
disabled dims the switch and blocks interaction in either state, keeping the off/on rendering legible.
"use client";
import { Switch } from "@stridge/noctis";
export default function SwitchDisabled() {
return (
<div className="flex items-center gap-6">
<Switch.Root aria-label="Disabled off" disabled>
<Switch.Thumb />
</Switch.Root>
<Switch.Root aria-label="Disabled on" disabled defaultChecked>
<Switch.Thumb />
</Switch.Root>
</div>
);
}
Keyboard
| Key | Action |
|---|---|
| Tab | Move focus to the switch. |
| Shift + Tab | Move focus to the previous control. |
| Space | Toggle the switch between off and on. |
| Enter | Toggle the switch between off and on. |
Anatomy
Compose the switch from its parts. Switch.Root is a Base UI Switch.Root, so the toggle behaviour, focus management, and the hidden form input come for free; Switch.Field is a Base UI Field.Root, which wires the label to the control.
Switch.Root— the track; owns the checked state (checked/onCheckedChange, or uncontrolleddefaultChecked) and the sharedsize. Disable it withdisabled, freeze it withreadOnly, submit it withname/value, demand it withrequired; name a standalone switch witharia-label.Switch.Thumb— the sliding knob inside the track. Its diameter re-points off the root'ssizethrough the cascade.Switch.Field— the row that frames a label and a switch as one control; hugs its content by default, and a full-width row isw-fullwith ajustify-between. Setsizehere to scale the label and the switch together.Switch.Label— the visible label; clicking it toggles the switch and its text supplies the accessible name.
Every rendered part carries a data-slot (noctis-switch on the track, noctis-switch-thumb on the knob, noctis-switch-field on the row, noctis-switch-label on the label) for host-side styling — pair it with the Base UI state attributes (data-checked, data-unchecked, data-disabled, data-readonly, data-invalid).
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 switch in that region retunes — e.g. .dense { --noctis-switch-block-size: 0.75rem; } shrinks the track beneath it. The checked and unchecked track colours flow from the accent and field roles, retuned by a retheme. 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 Root forwards the Base UI Switch.Root props it owns its state through, and the Field forwards Base UI's Field.Root. Expand a row for the full type and description.