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…

"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Check, Loader2, X } from "lucide-react"
import { cn } from "@/lib/utils"

const trackVariants = cva(
  [
    "relative inline-flex shrink-0 items-center rounded-full p-0.5 outline-none",
    "bg-input transition-colors data-[state=checked]:bg-primary motion-reduce:transition-none",
    "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
    "disabled:cursor-not-allowed",
  ],

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/toggle-switch.json

Prompt

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 checkboxrole="switch" plus aria-checked tells 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 − padding as its translate-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 lyingloading freezes 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 native disabled would blur the control the instant the write starts.
  • Clickable label block, single fire — the label is bound with aria-labelledby and 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.

On This Page