{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "switch",
  "type": "registry:ui",
  "title": "Switch",
  "description": "Sprung thumb that stretches while held, and waits on async saves in place.",
  "categories": [
    "selection"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/switch.tsx",
      "content": "\"use client\";\nimport { Switch as BaseSwitch } from \"@base-ui/react/switch\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Size = \"sm\" | \"md\";\n\n// Track and thumb geometry in px. The thumb sits 2px in from every edge and\n// grows by `stretch` toward the middle while pressed.\nconst dims = {\n  sm: { w: 28, h: 16, thumb: 12, stretch: 3 },\n  md: { w: 36, h: 20, thumb: 16, stretch: 4 },\n} as const;\n\nexport type SwitchProps = Omit<BaseSwitch.Root.Props, \"className\" | \"children\" | \"onCheckedChange\" | \"checked\" | \"defaultChecked\"> & {\n  checked?: boolean;\n  defaultChecked?: boolean;\n  /**\n   * Called with the next state. Return a promise and the switch moves at once,\n   * shows a spinner in its thumb while it waits, and moves back if it rejects.\n   */\n  onCheckedChange?: (checked: boolean) => void | Promise<unknown>;\n  /** Called when a returned promise rejects, after the switch has moved back. */\n  onError?: (error: unknown) => void;\n  /** Show the spinner and ignore input, for state you are loading yourself. */\n  loading?: boolean;\n  size?: Size;\n  /** A tick and a ring inside the track, so on and off don't rely on position alone. */\n  icons?: boolean;\n  label?: React.ReactNode;\n  description?: React.ReactNode;\n  /** Applied to the row when there is a label, otherwise to the track. */\n  className?: string;\n};\n\n/** Spinner timing: nothing for the first 150ms, then at least 300ms once shown. */\nfunction useDelayedBusy(busy: boolean) {\n  const [shown, setShown] = useState(false);\n  const since = useRef(0);\n  useEffect(() => {\n    if (busy) {\n      const t = window.setTimeout(() => {\n        since.current = performance.now();\n        setShown(true);\n      }, 150);\n      return () => window.clearTimeout(t);\n    }\n    const left = Math.max(0, 300 - (performance.now() - since.current));\n    const t = window.setTimeout(() => setShown(false), left);\n    return () => window.clearTimeout(t);\n  }, [busy]);\n  return shown;\n}\n\nexport function Switch({\n  checked: checkedProp,\n  defaultChecked = false,\n  onCheckedChange,\n  onError,\n  loading = false,\n  size = \"md\",\n  icons = false,\n  label,\n  description,\n  disabled,\n  readOnly,\n  className,\n  \"aria-describedby\": describedBy,\n  onKeyDown,\n  onKeyUp,\n  onBlur,\n  onPointerDown,\n  onPointerUp,\n  onPointerLeave,\n  onPointerCancel,\n  ...rest\n}: SwitchProps) {\n  const reduce = useReducedMotion();\n  const descriptionId = useId();\n  const [checked, setChecked] = useControllableState({ value: checkedProp, defaultValue: defaultChecked });\n  // While a returned promise is in flight, this is what the switch shows.\n  const [optimistic, setOptimistic] = useState<boolean | null>(null);\n  const [pressed, setPressed] = useState(false);\n  const pending = optimistic !== null;\n  const busy = loading || pending;\n  const spinning = useDelayedBusy(busy);\n  const on = optimistic ?? checked;\n  const idle = !disabled && !readOnly && !busy;\n\n  const d = dims[size];\n  const width = d.thumb + (pressed && idle ? d.stretch : 0);\n  const x = on ? d.w - 2 - width : 2;\n\n  const handleChange = (next: boolean) => {\n    const result = onCheckedChange?.(next);\n    if (result && typeof (result as Promise<unknown>).then === \"function\") {\n      setOptimistic(next);\n      (result as Promise<unknown>).then(\n        () => {\n          setChecked(next);\n          setOptimistic(null);\n        },\n        (error) => {\n          setOptimistic(null);\n          onError?.(error);\n        },\n      );\n    } else {\n      setChecked(next);\n    }\n  };\n\n  // With a label the whole row is the target, so the row tracks the press; alone, the track does.\n  // Your own pointer handlers still run on the track either way.\n  const press = {\n    onPointerDown: (e: React.PointerEvent) => e.button === 0 && setPressed(true),\n    onPointerUp: () => setPressed(false),\n    onPointerLeave: () => setPressed(false),\n    onPointerCancel: () => setPressed(false),\n  };\n  type TrackProps = BaseSwitch.Root.Props;\n  const trackPointer: Pick<TrackProps, \"onPointerDown\" | \"onPointerUp\" | \"onPointerLeave\" | \"onPointerCancel\"> = {\n    onPointerDown: (e) => {\n      onPointerDown?.(e);\n      if (label == null) press.onPointerDown(e);\n    },\n    onPointerUp: (e) => {\n      onPointerUp?.(e);\n      if (label == null) press.onPointerUp();\n    },\n    onPointerLeave: (e) => {\n      onPointerLeave?.(e);\n      if (label == null) press.onPointerLeave();\n    },\n    onPointerCancel: (e) => {\n      onPointerCancel?.(e);\n      if (label == null) press.onPointerCancel();\n    },\n  };\n\n  const track = (\n    <BaseSwitch.Root\n      checked={on}\n      onCheckedChange={handleChange}\n      disabled={disabled}\n      readOnly={readOnly || busy}\n      aria-busy={busy || undefined}\n      aria-describedby={cn(describedBy, description != null && descriptionId) || undefined}\n      data-size={size}\n      data-pending={busy || undefined}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (e.key === \" \") setPressed(true);\n      }}\n      onKeyUp={(e) => {\n        onKeyUp?.(e);\n        setPressed(false);\n      }}\n      onBlur={(e) => {\n        onBlur?.(e);\n        setPressed(false);\n      }}\n      {...trackPointer}\n      className={cn(\n        \"relative inline-block shrink-0 rounded-full outline-none\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"transition-[background-color] duration-200 ease-out-expo motion-reduce:transition-none\",\n        size === \"sm\" ? \"h-4 w-7\" : \"h-5 w-9\",\n        on ? \"bg-fg\" : \"bg-fg-4\",\n        idle && (on ? \"hover:bg-fg/90 group-hover/switch:bg-fg/90\" : \"hover:bg-fg-3 group-hover/switch:bg-fg-3\"),\n        disabled && \"cursor-not-allowed\",\n        // Touch: grow the target to 44px when the track stands alone.\n        label == null && \"after:absolute after:content-['']\",\n        label == null && (size === \"sm\" ? \"after:-inset-x-2 after:-inset-y-3.5\" : \"after:-inset-x-1 after:-inset-y-3\"),\n        label == null && disabled && \"opacity-50\",\n        label == null && className,\n      )}\n      {...rest}\n    >\n      {icons && (\n        <span aria-hidden className=\"pointer-events-none absolute inset-0 flex items-center justify-between px-[5px]\">\n          <svg\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2.2}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            className={cn(\"text-frame transition-opacity duration-150\", size === \"sm\" ? \"size-2\" : \"size-2.5\", on ? \"opacity-100\" : \"opacity-0\")}\n          >\n            <path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" />\n          </svg>\n          <svg\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2}\n            className={cn(\"text-fg-2 transition-opacity duration-150\", size === \"sm\" ? \"size-2\" : \"size-2.5\", on ? \"opacity-0\" : \"opacity-100\")}\n          >\n            <circle cx=\"8\" cy=\"8\" r=\"5\" />\n          </svg>\n        </span>\n      )}\n      <motion.span\n        aria-hidden\n        className={cn(\n          \"absolute left-0 top-0.5 grid place-items-center rounded-full transition-[background-color] duration-200 motion-reduce:transition-none\",\n          // Off, the thumb is the lightest thing on the track; on, it inverts with it.\n          on ? \"bg-frame\" : \"bg-raised dark:bg-fg-2\",\n        )}\n        style={{ height: d.thumb }}\n        initial={false}\n        animate={{ x, width }}\n        transition={reduce ? { duration: 0 } : spring.snappy}\n      >\n        <AnimatePresence initial={false}>\n          {spinning && (\n            <motion.span\n              key=\"spin\"\n              className=\"absolute grid place-items-center\"\n              initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={{ duration: 0.16, ease: ease.out }}\n            >\n              <svg\n                viewBox=\"0 0 16 16\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth={2}\n                strokeLinecap=\"round\"\n                // Contrast against the thumb in both states: the off thumb is fg-2 in dark mode.\n                className={cn(\"animate-spin-slow\", on ? \"text-fg-2\" : \"text-fg-3 dark:text-frame\", size === \"sm\" ? \"size-2\" : \"size-2.5\")}\n              >\n                <path d=\"M8 2.25a5.75 5.75 0 1 0 5.75 5.75\" />\n              </svg>\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </motion.span>\n    </BaseSwitch.Root>\n  );\n\n  if (label == null) return track;\n\n  return (\n    <label\n      {...press}\n      className={cn(\n        \"group/switch flex select-none items-start justify-between gap-4\",\n        \"has-[[data-disabled]]:opacity-50\",\n        className,\n      )}\n    >\n      <span className=\"flex min-w-0 flex-col gap-px\">\n        <span className=\"text-[13px] leading-5 text-fg\">{label}</span>\n        {description != null && (\n          <span id={descriptionId} className=\"text-[12.5px] leading-[18px] text-fg-3\">\n            {description}\n          </span>\n        )}\n      </span>\n      <span className=\"flex h-5 items-center\">{track}</span>\n    </label>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/switch"
}
