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/switch";
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 } from "@stridge/noctis";
import { Switch } from "@stridge/noctis/switch";
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/switch";
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 } from "@stridge/noctis/button";
import { Switch } from "@stridge/noctis/switch";
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 } from "@stridge/noctis/button";
import { Switch } from "@stridge/noctis/switch";
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/switch";
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/switch";
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.
Theme scope
ThemeScope re-tunes a whole region's shape — corner radius, density, and type scale — through the cascade, with no per-component prop and without touching the app-wide theme. Tweak the knobs below and watch every switch inside the scope retune together. See Customization for nesting, reset, and the portal caveat.
"use client";
import { DENSITY_PRESETS, FONT_SCALE_PRESETS, RADIUS_PRESETS, ThemeScope } from "@stridge/noctis";
import { Select } from "@stridge/noctis/select";
import { Switch } from "@stridge/noctis/switch";
import { useState } from "react";
/** Title-case a preset key for its select label. */
const label = (key: string) => key.charAt(0).toUpperCase() + key.slice(1);
/** One labelled preset picker — its options are a shipped `ThemeScope` preset map, so the playground's
* vocab is exactly what the primitive accepts. */
function Knob({
name,
presets,
value,
onChange,
}: {
name: string;
presets: Record<string, number>;
value: string;
onChange: (value: string) => void;
}) {
const keys = Object.keys(presets);
const items = Object.fromEntries(keys.map((key) => [key, label(key)]));
return (
<label className="flex flex-col gap-1.5">
<span className="text-mini font-medium text-subtle">{name}</span>
<Select.Root items={items} value={value} onValueChange={(next) => onChange(String(next))}>
<Select.Trigger aria-label={name} className="w-40">
<Select.Value />
<Select.Icon />
</Select.Trigger>
<Select.Popup>
{keys.map((key) => (
<Select.Item key={key} value={key}>
{label(key)}
</Select.Item>
))}
</Select.Popup>
</Select.Root>
</label>
);
}
/**
* A live `ThemeScope` playground: the three seed knobs (radius, density, type scale) drive a scope
* wrapping a switch cluster, so every control inside re-tunes together — spacing and text size — with
* no per-switch prop and without touching the app-wide theme. Switch tracks are pill-shaped by
* construction; density and font scale are the primary dials here.
*/
export default function SwitchThemeScope() {
const [radius, setRadius] = useState<keyof typeof RADIUS_PRESETS>("pill");
const [density, setDensity] = useState<keyof typeof DENSITY_PRESETS>("default");
const [fontScale, setFontScale] = useState<keyof typeof FONT_SCALE_PRESETS>("default");
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap gap-3">
<Knob name="Radius" presets={RADIUS_PRESETS} value={radius} onChange={(v) => setRadius(v as typeof radius)} />
<Knob
name="Density"
presets={DENSITY_PRESETS}
value={density}
onChange={(v) => setDensity(v as typeof density)}
/>
<Knob
name="Font scale"
presets={FONT_SCALE_PRESETS}
value={fontScale}
onChange={(v) => setFontScale(v as typeof fontScale)}
/>
</div>
<div className="rounded-md border border-dashed border-border p-4">
<ThemeScope radius={radius} density={density} fontScale={fontScale}>
<div className="flex flex-col gap-3">
<Switch.Field>
<Switch.Label>Notifications</Switch.Label>
<Switch.Root defaultChecked>
<Switch.Thumb />
</Switch.Root>
</Switch.Field>
<Switch.Field>
<Switch.Label>Marketing emails</Switch.Label>
<Switch.Root>
<Switch.Thumb />
</Switch.Root>
</Switch.Field>
</div>
</ThemeScope>
</div>
</div>
);
}
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.