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.json

Usage

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

  1. Radix-compatible API. The value is a boolean. aria-checked mirrors it as "true" | "false" so screen readers announce the state correctly, and data-state="checked" | "unchecked" carries the same value as a CSS hook for styling each state independently.
  2. 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 when name is provided, so the value still participates in native <form> submission.
  3. Controlled + uncontrolled. Pass checked for fully controlled use, or defaultChecked (or nothing) for uncontrolled. Mirrors the Radix Switch.Root API so swapping in or out is a one-line change.
  4. Spring-driven thumb. The thumb slides between x=0 (unchecked) and x=20 (checked) on SPRINGS.snap — a critically-tuned spring, no overshoot — and a TAP_SCALE whileTap provides the canonical tap-down feedback. Reduced-motion short-circuits the spring at the Motion runtime layer.
  5. Label slot wires for free. Pass label and a sibling <label htmlFor> is rendered automatically, so clicking the text toggles the switch.
  6. 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

PropTypeDefaultDescription
checkedbooleanControlled checked state. Pair with onCheckedChange.
defaultCheckedbooleanfalseUncontrolled initial state. Ignored when checked is provided.
onCheckedChange(checked: boolean) => voidFired with the next checked state on every user toggle.
labelReactNodeOptional label rendered to the right of the track. Wired via htmlFor so clicking it toggles the switch.
disabledbooleanfalseDisables pointer + keyboard input. The label fades.
requiredbooleanfalseSets aria-required and propagates to the hidden mirror input when name is set.
namestringAdds a hidden mirror <input type="checkbox"> so the value participates in native form submission.
valuestring"on"Submitted value when name is set and the switch is checked.
classNamestringMerged onto the underlying <button>.

Accessibility

  • The track renders as <button role="switch"> with aria-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 label is 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).
  • disabled propagates to the button (no keyboard / pointer reachability) and the label (cursor-not-allowed, reduced opacity).
  • required is reflected as aria-required on the button and propagated to the hidden mirror input when name is set, so native form validation reports a missing required switch.

Credits

  • Extracted from: algoflashcards (src/platform/ui/switch.tsx). The source wrapped radix-ui's Switch.Root and Switch.Thumb with a motion.span thumb sliding on an inline SPRING.snappy and a hand-tuned multi-layer inset shadow. craft-bits drops the radix-ui dependency — the track is a plain <button role="switch"> and the value flows through React state — wires the thumb to SPRINGS.snap and TAP_SCALE from @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.