{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"toggle-switch","type":"registry:component","title":"Toggle switch","description":"Leans into the move when pressed, then springs across and squashes on landing.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/toggle-switch.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { animate, motion, useMotionValue, useTransform } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// Track is 48x28 with 2px padding, so the knob moves inside 44px.\nconst INNER = 44;\nconst KNOB = 24;\n// Pressed, the knob leans 7px into the direction it will travel.\nconst STRETCHED = 31;\n// Movement below this is still a tap, so a shaky finger doesn't drag.\nconst DRAG_SLOP = 3;\n// Landing past the end squashes the knob against the wall instead of\n// poking out of the track. Capped so a hard flick can't flatten it.\nconst MAX_SQUASH = 5;\n// The playful case: a little bounce sells the knob as a physical thing.\n// 0.3s is the visual settle; the overshoot tail runs slightly past it.\nconst TRAVEL = { type: \"spring\", visualDuration: 0.3, bounce: 0.25 } as const;\nconst LEAN = { type: \"spring\", duration: 0.2, bounce: 0 } as const;\nconst GLIDE = { duration: 0.2, ease: [0.23, 1, 0.32, 1] } as const;\n\nconst clamp01 = (v: number) => Math.min(Math.max(v, 0), 1);\n\nexport function ToggleSwitch({\n  checked,\n  onCheckedChange,\n  label,\n  className,\n}: {\n  checked: boolean;\n  onCheckedChange: (checked: boolean) => void;\n  label: string;\n  className?: string;\n}) {\n  const id = useId();\n  const reduceMotion = useReducedMotion();\n  // 0 is off, 1 is on. The spring overshoots past either end on landing.\n  const progress = useMotionValue(checked ? 1 : 0);\n  const width = useMotionValue(KNOB);\n  const target = useRef(checked);\n  const gesture = useRef<{\n    pointerId: number;\n    startX: number;\n    startProgress: number;\n    dragging: boolean;\n  } | null>(null);\n  // A drag ends in a click event too; that click must not toggle again.\n  const swallowClick = useRef(false);\n\n  const squash = useTransform(progress, (p) => {\n    const over = p > 1 ? p - 1 : p < 0 ? -p : 0;\n    return Math.min(over * (INNER - KNOB), MAX_SQUASH);\n  });\n  const knobWidth = useTransform([width, squash], ([w, s]: number[]) => w - s);\n  // Position is derived from width, so widening while off grows rightward\n  // and widening while on grows leftward: always into the coming move.\n  const knobX = useTransform(\n    [progress, knobWidth],\n    ([p, w]: number[]) => clamp01(p) * (INNER - w),\n  );\n  const fill = useTransform(progress, clamp01);\n\n  const travelTo = (next: boolean) => {\n    target.current = next;\n    animate(\n      progress,\n      next ? 1 : 0,\n      // Carries the release speed of a drag into the spring.\n      reduceMotion ? GLIDE : { ...TRAVEL, velocity: progress.getVelocity() },\n    );\n  };\n\n  const lean = (on: boolean) => {\n    if (reduceMotion) return;\n    animate(width, on ? STRETCHED : KNOB, on ? LEAN : TRAVEL);\n  };\n\n  // Follows changes made outside this switch; our own already animated.\n  useEffect(() => {\n    if (target.current !== checked) travelTo(checked);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [checked]);\n\n  const commit = (next: boolean) => {\n    travelTo(next);\n    if (next !== checked) onCheckedChange(next);\n  };\n\n  const endGesture = () => {\n    gesture.current = null;\n    lean(false);\n  };\n\n  return (\n    <div\n      className={cn(\"flex items-center justify-between gap-4 py-3\", className)}\n    >\n      <label\n        htmlFor={id}\n        className=\"cursor-pointer text-[15px] text-foreground select-none\"\n      >\n        {label}\n      </label>\n      <button\n        id={id}\n        type=\"button\"\n        role=\"switch\"\n        aria-checked={checked}\n        // The knob's stretch is the press feedback, so the track doesn't\n        // also scale: shrinking it would shift the knob under the finger\n        // mid-drag. The pseudo-element grows the hit area to 56x44.\n        className=\"relative h-7 w-12 shrink-0 cursor-pointer touch-none rounded-full bg-foreground/15 outline-hidden select-none after:absolute after:-inset-x-1 after:-inset-y-2 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground dark:bg-foreground/25\"\n        onPointerDown={(e) => {\n          if (e.button !== 0 || gesture.current) return;\n          swallowClick.current = false;\n          e.currentTarget.setPointerCapture(e.pointerId);\n          gesture.current = {\n            pointerId: e.pointerId,\n            startX: e.clientX,\n            startProgress: clamp01(progress.get()),\n            dragging: false,\n          };\n          lean(true);\n        }}\n        onPointerMove={(e) => {\n          const g = gesture.current;\n          if (!g || e.pointerId !== g.pointerId) return;\n          const dx = e.clientX - g.startX;\n          if (!g.dragging && Math.abs(dx) < DRAG_SLOP) return;\n          g.dragging = true;\n          // Pointer px map to knob px 1:1 at the knob's current width.\n          progress.set(clamp01(g.startProgress + dx / (INNER - width.get())));\n        }}\n        onPointerUp={(e) => {\n          const g = gesture.current;\n          if (!g || e.pointerId !== g.pointerId) return;\n          if (g.dragging) {\n            swallowClick.current = true;\n            commit(progress.get() > 0.5);\n          }\n          endGesture();\n        }}\n        onPointerCancel={(e) => {\n          const g = gesture.current;\n          if (!g || e.pointerId !== g.pointerId) return;\n          if (g.dragging) travelTo(target.current);\n          endGesture();\n        }}\n        onKeyDown={() => {\n          swallowClick.current = false;\n        }}\n        // Taps, label clicks, Space and Enter all land here. Keyboard and\n        // label never press the knob, so they travel without the stretch.\n        onClick={() => {\n          if (swallowClick.current) {\n            swallowClick.current = false;\n            return;\n          }\n          commit(!target.current);\n        }}\n      >\n        <motion.span\n          aria-hidden\n          className=\"absolute inset-0 rounded-full bg-foreground\"\n          style={{ opacity: fill }}\n        />\n        <motion.span\n          aria-hidden\n          className=\"absolute top-0.5 left-0.5 h-6 rounded-full bg-background shadow-raised\"\n          style={{ x: knobX, width: knobWidth }}\n        />\n      </button>\n    </div>\n  );\n}\n\nconst SETTINGS = [\"Reduce motion\", \"Sound effects\", \"Haptics\"] as const;\n\nexport default function ToggleSwitchDemo() {\n  const [values, setValues] = useState<Record<string, boolean>>({\n    \"Reduce motion\": false,\n    \"Sound effects\": true,\n    Haptics: true,\n  });\n\n  return (\n    <div className=\"w-[380px] max-w-full divide-y divide-border\">\n      {SETTINGS.map((name) => (\n        <ToggleSwitch\n          key={name}\n          label={name}\n          checked={values[name]}\n          onCheckedChange={(next) =>\n            setValues((v) => ({ ...v, [name]: next }))\n          }\n        />\n      ))}\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["buttons"],"docs":"From ui lab: https://lab.xevrion.dev/lab/toggle-switch"}