Switch
A Radix-style toggle switch primitive. The value is a boolean flowing in via checked and out via onCheckedChange, with a parallel defaultChecked for the uncontrolled path. The track renders as a real <button role="switch"> so screen readers see aria-checked="true" | "false", and the focus ring, track fill, and animated thumb are fully styleable through the --cb-* token system.
Preview
Installation
npx shadcn@latest add https://craftbits.dev/r/switch.jsonUsage
Controlled — pair checked with onCheckedChange so a parent owns the value:
import { Switch } from "@craft-bits/core";
const [airplane, setAirplane] = useState(false);
<Switch
checked={airplane}
onCheckedChange={setAirplane}
label="Airplane mode"
/>Uncontrolled — let the switch own the value, seed it via defaultChecked:
<Switch defaultChecked={false} label="Auto-save drafts" />Inside a form — set name so the value participates in native form submission:
<form>
<Switch name="newsletter" value="weekly" defaultChecked label="Weekly digest" />
<button type="submit">Save</button>
</form>Understanding the component
- Radix-compatible API. The value is a boolean.
aria-checkedmirrors it as"true" | "false"so screen readers announce the state correctly, anddata-state="checked" | "unchecked"carries the same value as a CSS hook for styling each state independently. - Button under the hood. The track is a real
<button role="switch">, not a styled native<input>. That trades cross-browser styling quirks for a focus ring, track fill, and animated thumb that paint identically on every engine. A hidden<input type="checkbox">is appended only whennameis provided, so the value still participates in native<form>submission. - Controlled + uncontrolled. Pass
checkedfor fully controlled use, ordefaultChecked(or nothing) for uncontrolled. Mirrors the RadixSwitch.RootAPI so swapping in or out is a one-line change. - Spring-driven thumb. The thumb slides between
x=0(unchecked) andx=20(checked) onSPRINGS.snap— a critically-tuned spring, no overshoot — and aTAP_SCALEwhileTapprovides the canonical tap-down feedback. Reduced-motion short-circuits the spring at the Motion runtime layer. - Label slot wires for free. Pass
labeland a sibling<label htmlFor>is rendered automatically, so clicking the text toggles the switch. - Token-only theming. Track fill, thumb surface, focus halo all read from
--cb-*tokens (--cb-accent,--cb-bg-muted,--cb-bg-elevated,--cb-accent-muted). Theme swaps and dark mode repaint without a prop change.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | — | Controlled checked state. Pair with onCheckedChange. |
defaultChecked | boolean | false | Uncontrolled initial state. Ignored when checked is provided. |
onCheckedChange | (checked: boolean) => void | — | Fired with the next checked state on every user toggle. |
label | ReactNode | — | Optional label rendered to the right of the track. Wired via htmlFor so clicking it toggles the switch. |
disabled | boolean | false | Disables pointer + keyboard input. The label fades. |
required | boolean | false | Sets aria-required and propagates to the hidden mirror input when name is set. |
name | string | — | Adds a hidden mirror <input type="checkbox"> so the value participates in native form submission. |
value | string | "on" | Submitted value when name is set and the switch is checked. |
className | string | — | Merged onto the underlying <button>. |
Accessibility
- The track renders as
<button role="switch">witharia-checked="true" | "false", so screen readers announce the state correctly. data-state="checked" | "unchecked"mirrors the value as a CSS hook so each state can be styled independently without prop-derived classes.- When
labelis provided, a sibling<label htmlFor>is rendered with the same id as the button — clicking the label text toggles the switch. - Keyboard: Space and Enter toggle the switch (native
<button>semantics), Tab moves focus in DOM order. Focus is visible via a token-driven ring (--cb-accent-muted). disabledpropagates to the button (no keyboard / pointer reachability) and the label (cursor-not-allowed, reduced opacity).requiredis reflected asaria-requiredon the button and propagated to the hidden mirror input whennameis set, so native form validation reports a missing required switch.
Credits
- Extracted from:
algoflashcards(src/platform/ui/switch.tsx). The source wrappedradix-ui'sSwitch.RootandSwitch.Thumbwith amotion.spanthumb sliding on an inlineSPRING.snappyand a hand-tuned multi-layer inset shadow. craft-bits drops theradix-uidependency — the track is a plain<button role="switch">and the value flows through React state — wires the thumb toSPRINGS.snapandTAP_SCALEfrom@craft-bits/core/motion, broadens the API with an optional label slot and a hidden mirror input for native form submission, and rebases the styling on--cb-*tokens so the same primitive paints correctly in every theme.