{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "switch",
  "type": "registry:ui",
  "title": "Switch",
  "description": "An accessible React switch for on/off settings, with a spring-animated thumb, built on Radix Switch.",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "class-variance-authority@^0.7.1",
    "motion@^14.0.0",
    "radix-ui@^1.7.0"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/switch.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { spring } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport { Switch as SwitchPrimitive } from \"radix-ui\";\nimport { type ComponentProps, useEffect, useLayoutEffect, useRef, useState } from \"react\";\n\n/*\n * The thumb's motion, start to finish (owner, 2026-10-14):\n * - Hover: the thumb stays at its end and stretches 2px towards the other side, as if it wants to go there.\n * - Press: it stretches further.\n * - Change: it travels still stretched, then settles round at the new end, so it moves like something\n *   soft rather than sliding. A keyboard change stretches the same way.\n * - The \"on\" colour is a pill under the thumb, driven only by the thumb's position, so going off is going\n *   on played backwards (owner, 2026-10-14):\n *   - It always ends at the thumb's far edge. Going on it sweeps across with the thumb; going off the grey\n *     follows in behind the thumb the same way.\n *   - Its opacity follows the thumb's progress. Going on, it fades in over the start of the trip. Going\n *     off, it stays solid most of the way, then reaches zero exactly as the thumb arrives, so no ring of\n *     it is left around the thumb.\n * - No shadows.\n *\n * The thumb is placed with numbers rather than measured: `x` from the track's inner width, and its own\n * width (a small absolutely positioned pill, so nothing else reflows), both motion values on\n * spring.snappy, with the colour derived from them. Measuring would break inside anything scaled or\n * zoomed, such as a zoomed preview or a dialog scaling in. The numbers are in rem and match the size\n * classes. With reduced motion the thumb jumps and doesn't stretch.\n */\n\nconst switchVariants = cva(\n  [\n    \"relative isolate inline-flex shrink-0 cursor-pointer overflow-hidden rounded-full bg-input\",\n    \"outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)\",\n    \"disabled:cursor-not-allowed disabled:opacity-(--opacity-disabled)\",\n  ],\n  {\n    variants: {\n      size: {\n        sm: \"h-4 w-7\",\n        md: \"h-5 w-9\",\n      },\n    },\n    defaultVariants: { size: \"md\" },\n  },\n);\n\n/**\n * The thumb, in rem, matching the track classes above: the track's width less its 0.125rem padding on\n * each side (`inner`), and the thumb's width at rest and stretched. Its height is the class.\n */\nconst thumbs = {\n  sm: { height: \"h-3\", inner: 1.5, rest: 0.75, stretched: 1 },\n  md: { height: \"h-4\", inner: 2, rest: 1, stretched: 1.25 },\n} as const;\n\n/** How far the thumb stretches on hover (2px) */\nconst HOVER_STRETCH = 0.125;\n/** How long the thumb stays stretched after a change, while it travels */\nconst TRAVEL_MS = 140;\n/**\n * The colour's opacity from the thumb's progress (0 off, 1 on): 1.6√p, fitted to the original on fade. The\n * last 3% of the trip (half a pixel) counts as home, so the spring's slow settle leaves no faint ring.\n */\nconst fillOpacityAt = (progress: number) =>\n  Math.min(1, 1.6 * Math.sqrt(Math.max(0, (progress - 0.03) / 0.97)));\n\nexport type SwitchProps = ComponentProps<typeof SwitchPrimitive.Root> & VariantProps<typeof switchVariants>;\n\n/**\n * An on/off switch for settings that apply right away, built on Radix Switch (role=\"switch\").\n * Give it a visible label (`<label htmlFor>`).\n */\nfunction Switch({\n  className,\n  size,\n  checked,\n  defaultChecked,\n  disabled,\n  onCheckedChange,\n  onPointerDown,\n  onPointerUp,\n  onPointerEnter,\n  onPointerLeave,\n  ...props\n}: SwitchProps) {\n  // Mirror the state so the thumb knows when it changed, controlled or not\n  const [uncontrolled, setUncontrolled] = useState(defaultChecked ?? false);\n  const on = checked ?? uncontrolled;\n  const [pressed, setPressed] = useState(false);\n  const [hovered, setHovered] = useState(false);\n  const [travelling, setTravelling] = useState(false);\n  const root = useRef<HTMLButtonElement>(null);\n  const reduceMotion = useReducedMotion();\n  const thumb = thumbs[size ?? \"md\"];\n\n  // After a change the thumb stays stretched for the trip, then settles round at the new end\n  const previous = useRef(on);\n  useEffect(() => {\n    if (previous.current === on) return;\n    previous.current = on;\n    if (reduceMotion) return;\n    setTravelling(true);\n    const timer = setTimeout(() => setTravelling(false), TRAVEL_MS);\n    return () => clearTimeout(timer);\n  }, [on, reduceMotion]);\n\n  // Pressed or travelling: fully stretched. Hovered: a little. Never with reduced motion or when disabled.\n  const width = reduceMotion\n    ? thumb.rest\n    : pressed || travelling\n      ? thumb.stretched\n      : hovered && !disabled\n        ? thumb.rest + HOVER_STRETCH\n        : thumb.rest;\n  // Off: at the start. On: at the end, its far edge fixed, so stretching always reaches towards the other side.\n  const offset = on ? thumb.inner - width : 0;\n  const move = reduceMotion ? { duration: 0 } : spring.snappy;\n\n  // The thumb's position and width (rem) and the page direction (1, or -1 right to left), as motion values,\n  // so the colour can follow the thumb frame by frame\n  const x = useMotionValue(offset);\n  const w = useMotionValue(width);\n  const sign = useMotionValue(1);\n\n  useLayoutEffect(() => {\n    if (root.current) sign.set(getComputedStyle(root.current).direction === \"rtl\" ? -1 : 1);\n  }, [sign]);\n  useEffect(() => {\n    const animation = animate(x, offset, move);\n    return () => animation.stop();\n  }, [x, offset, move]);\n  useEffect(() => {\n    const animation = animate(w, width, move);\n    return () => animation.stop();\n  }, [w, width, move]);\n\n  const thumbX = useTransform(\n    [sign, x],\n    ([direction = 1, position = 0]: number[]) => `${direction * position}rem`,\n  );\n  const thumbWidth = useTransform(w, (value) => `${value}rem`);\n  // The colour ends at the thumb's far edge (full across when on) and fades with the thumb's progress, both\n  // ways, so going off is going on played backwards\n  const fillX = useTransform(\n    [sign, x, w],\n    ([direction = 1, position = 0, size = 0]: number[]) =>\n      `${direction * (position + size - thumb.inner)}rem`,\n  );\n  const fillOpacity = useTransform(x, (position) => fillOpacityAt(position / (thumb.inner - thumb.rest)));\n\n  return (\n    <SwitchPrimitive.Root\n      ref={root}\n      data-slot=\"switch\"\n      checked={checked}\n      defaultChecked={defaultChecked}\n      disabled={disabled}\n      onCheckedChange={(value) => {\n        setUncontrolled(value);\n        // No hover stretch straight after a change, or the thumb would seem to want to go back; it\n        // returns once the pointer leaves and comes back\n        setHovered(false);\n        onCheckedChange?.(value);\n      }}\n      onPointerEnter={(event) => {\n        if (event.pointerType === \"mouse\") setHovered(true);\n        onPointerEnter?.(event);\n      }}\n      onPointerDown={(event) => {\n        setPressed(true);\n        onPointerDown?.(event);\n      }}\n      onPointerUp={(event) => {\n        setPressed(false);\n        onPointerUp?.(event);\n      }}\n      onPointerLeave={(event) => {\n        setPressed(false);\n        setHovered(false);\n        onPointerLeave?.(event);\n      }}\n      className={cn(switchVariants({ size }), className)}\n      {...props}\n    >\n      <motion.span\n        aria-hidden\n        data-slot=\"switch-fill\"\n        style={{ x: fillX, opacity: fillOpacity }}\n        className=\"pointer-events-none absolute inset-0 -z-10 rounded-full bg-primary\"\n      />\n      <SwitchPrimitive.Thumb asChild>\n        <motion.span\n          data-slot=\"switch-thumb\"\n          style={{ x: thumbX, width: thumbWidth }}\n          className={cn(\n            \"pointer-events-none absolute start-0.5 top-0.5 block rounded-full bg-background transition-colors duration-(--duration-base) ease-out data-[state=checked]:bg-primary-foreground\",\n            thumb.height,\n          )}\n        />\n      </SwitchPrimitive.Thumb>\n    </SwitchPrimitive.Root>\n  );\n}\n\nexport { Switch, switchVariants };\n"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/switch"
}
