Inputs
Toggle Switch
A switch with a sliding knob, Check/X icons fading inside the track, three sizes and a loading state.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/toggle-switch.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ToggleSwitch" component
(class-variance-authority + lucide-react Check / X / Loader2).
Contract
- Export a forwardRef <button type="button" role="switch"> extending
ButtonHTMLAttributes (minus type/onChange/value) plus VariantProps of the
track cva; the ref points at the button.
- checked? + onCheckedChange?(checked) for controlled use, defaultChecked
for uncontrolled — presence of `checked` picks the mode.
- size: "sm" | "md" | "lg" (default "md"), disabled, loading,
label?: ReactNode, description?: ReactNode.
- className lands on the row wrapper, not on the track: every other native prop
and the ref go to the button, so resize the track with `size` rather than a
utility class (a className like "h-8 w-14" would silently stretch the wrapper).
- The accessible name comes from `label` via aria-labelledby. A bare switch
(no label) therefore has no name of its own — the consumer must pass
aria-label.
Behavior
- A real button carries the semantics: Space and Enter activate it natively,
aria-checked mirrors the state, data-state="checked | unchecked" drives the
visuals. No native checkbox appearance anywhere.
- Uncontrolled mode keeps the state internally; controlled mode only calls
onCheckedChange. The consumer's onClick still runs after the toggle.
- loading keeps the knob exactly where it is (no optimistic slide), renders a
small spinning Loader2 inside the knob, sets aria-busy and blocks toggling in
the handler with a cursor-wait affordance — the write is in flight, so the UI
must not lie about the outcome. It stays focusable while loading: setting the
native disabled attribute would blur the switch the moment the consumer flips
`loading`, dropping the keyboard user out of the tab order mid-interaction.
- When label / description are given, the switch and the text sit in one
flex row: the label is wired as the accessible name via aria-labelledby,
the description via aria-describedby (both ids from useId). A click
anywhere on that row toggles, except when it already landed inside the
button — that guard is why this uses aria-labelledby + an explicit handler
rather than a <label> wrapper, which would double-fire.
- disabled dims the row and stops both paths.
Rendering & styling
- Two cva definitions share one `size` axis. The track carries
bg-input + data-[state=checked]:bg-primary, rounded-full, p-0.5 and
h-5/w-9 · h-6/w-11 · h-7/w-13. The knob is bg-background, rounded-full and
size-4/5/6 with data-[state=checked]:translate-x-4/5/6 — travel is
track − knob − padding, so the knob always lands flush against the far edge.
- Check and X live in one absolutely positioned, aria-hidden layer with
justify-between px-1: Check at the left end (text-primary-foreground),
X at the right end (text-muted-foreground). The opaque knob covers whichever
end it parks on, and the icons cross-fade on opacity — no second animation
to keep in sync with the slide.
- Semantic tokens only; focus-visible:ring-2 ring-ring with an offset in
ring-offset-background.
- Knob transform, icon opacity and track colour all carry
motion-reduce:transition-none, so reduced motion snaps between the two
states with everything still legible.
Customization levers
- Sizes: the two cva size maps are the whole scale — add an "xs" row by
keeping track − knob − 4px = travel.
- Icons: swap Check/X for Moon/Sun (theme switch) or Lock/Unlock; the layer's
geometry is icon-agnostic, only the size classes change.
- Icons off: delete the icon layer for a plain shadcn-style switch — nothing
else depends on it.
- Colour: data-[state=checked]:bg-primary is the accent hook; use
bg-destructive for dangerous toggles, and keep the Check in the matching
*-foreground token.
- Copy density: label + description is a two-line block; drop description for
a compact settings row, or render the switch on the right by reversing the
flex direction.
- Native form posting: this control is a button, so add a visually hidden
<input type="checkbox" name value> mirroring `checked` if you need the
value in a non-JS form submission.Concepts
- Switch, not checkbox —
role="switch"plusaria-checkedtells assistive tech the change applies immediately, which is the semantic difference from a checkbox that waits for a form submit. - Knob travel by size — each size defines
track − knob − paddingas itstranslate-x, so adding a size is one row in each cva map instead of new positioning logic. - In-track state icons — Check and X are pinned to the two ends of the track and cross-fade on opacity; the opaque knob physically covers the one it parks on, so a single slide animation carries the whole state change.
- Pending without lying —
loadingfreezes the knob at the current state rather than sliding optimistically, so the switch never shows an outcome the server has not confirmed; it stays focusable and refuses the toggle in the handler, because a nativedisabledwould blur the control the instant the write starts. - Clickable label block, single fire — the label is bound with
aria-labelledbyand the row's click handler ignores clicks that already hit the button, avoiding the double-toggle a<label>wrapper produces around a real button. - Reduced motion keeps the state, drops the travel — every transition is
motion-reduce:transition-none; the switch reads exactly the same, it just stops sliding.