{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"switch","type":"registry:component","title":"Switch","description":"A switch with weight: the thumb stretches on press, is thrown across with a slight overshoot, and spins while an async save settles.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/switch.tsx","type":"registry:component","content":"// Hairline UI · switch · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { EASE_OUT, EASE_THROW, INTERACTION_FOCUS, interactionIcon, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * SWITCH: on and off, with weight\n *\n *   press    the thumb stretches toward the side it will go\n *   flip     it's thrown across with a slight overshoot while the\n *            track fills; the fill and thumb move as one\n *   async    if onCheckedChange returns a promise, the switch\n *            flips at once and the thumb spins until it settles;\n *            a rejection throws it back and shakes the track\n * ───────────────────────────────────────────────────────── */\n\nexport interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"onChange\" | \"value\"> {\n  checked?: boolean;\n  defaultChecked?: boolean;\n  /** Return a promise to show the pending thumb; throw to put it back. */\n  onCheckedChange?: (checked: boolean) => void | Promise<unknown>;\n  label?: React.ReactNode;\n  description?: React.ReactNode;\n  /** 16px or 20px tall. */\n  size?: \"sm\" | \"md\";\n  /** Put the label before the switch (settings rows). */\n  labelPosition?: \"start\" | \"end\";\n  ref?: React.Ref<HTMLButtonElement>;\n}\n\nconst SIZES = { sm: { w: 28, h: 16, pad: 2 }, md: { w: 36, h: 20, pad: 2 } };\n\nexport function Switch({\n  checked,\n  defaultChecked = false,\n  onCheckedChange,\n  label,\n  description,\n  size = \"md\",\n  labelPosition = \"end\",\n  disabled,\n  className = \"\",\n  id,\n  ref,\n  ...props\n}: SwitchProps) {\n  const reduced = useInteractionReducedMotion();\n  const auto = React.useId();\n  const switchId = id ?? auto;\n  const track = React.useRef<HTMLSpanElement>(null);\n  const [own, setOwn] = React.useState(defaultChecked);\n  const [optimistic, setOptimistic] = React.useState<boolean | null>(null);\n  const [pressed, setPressed] = React.useState(false);\n  const on = optimistic ?? (checked !== undefined ? checked : own);\n  const pending = optimistic !== null;\n  const s = SIZES[size];\n  const thumb = s.h - s.pad * 2;\n  const travel = s.w - thumb - s.pad * 2;\n  const stretch = pressed && !reduced ? 4 : 0;\n\n  const toggle = () => {\n    if (disabled || pending) return;\n    const next = !on;\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          if (checked === undefined) setOwn(next);\n          setOptimistic(null);\n        },\n        () => {\n          setOptimistic(null);\n          if (!reduced) track.current?.animate([{ transform: \"none\" }, { transform: \"translateX(-3px)\" }, { transform: \"translateX(3px)\" }, { transform: \"none\" }], { duration: 320, easing: \"ease-out\" });\n        },\n      );\n    } else if (checked === undefined) setOwn(next);\n  };\n\n  const control = (\n    <button\n      ref={ref}\n      id={switchId}\n      type=\"button\"\n      role=\"switch\"\n      aria-checked={on}\n      aria-busy={pending || undefined}\n      disabled={disabled}\n      onClick={toggle}\n      onPointerDown={() => setPressed(true)}\n      onPointerUp={() => setPressed(false)}\n      onPointerLeave={() => setPressed(false)}\n      onBlur={() => setPressed(false)}\n      className={`relative inline-flex shrink-0 rounded-full cursor-pointer disabled:cursor-default disabled:opacity-50 ${INTERACTION_FOCUS}`}\n      style={{ width: s.w, height: s.h }}\n      {...props}\n    >\n      <span ref={track} className=\"absolute inset-0 overflow-hidden rounded-full bg-muted shadow-[inset_0_0_0_1px_var(--border)]\">\n        <span\n          className=\"absolute inset-0 rounded-full bg-primary\"\n          style={{ opacity: on ? 1 : 0, transition: reduced ? \"none\" : `opacity 260ms ${EASE_OUT}` }}\n        />\n      </span>\n      <span\n        className=\"absolute rounded-full bg-background shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_10%,transparent)]\"\n        style={{\n          top: s.pad,\n          left: s.pad,\n          height: thumb,\n          width: thumb + stretch,\n          // Stretching toward where it's going: grow from the far edge when on.\n          transform: `translateX(${on ? travel - stretch : 0}px)`,\n          transition: reduced ? \"none\" : `transform 420ms ${EASE_THROW}, width 260ms ${EASE_OUT}`,\n        }}\n      >\n        <span className=\"absolute inset-0 flex items-center justify-center text-primary\" style={interactionIcon(pending, reduced)}>\n          <span className={`size-[60%] rounded-full border-[1.5px] border-current border-t-transparent ${pending ? \"animate-spin\" : \"\"}`} />\n        </span>\n      </span>\n    </button>\n  );\n\n  if (!label && !description) return <span className={className}>{control}</span>;\n\n  return (\n    <div className={`flex items-start gap-3 ${labelPosition === \"start\" ? \"flex-row-reverse justify-between\" : \"\"} ${className}`}>\n      <span className=\"mt-0.5\">{control}</span>\n      <label htmlFor={switchId} className={`flex min-w-0 flex-col gap-0.5 ${disabled ? \"opacity-50\" : \"cursor-pointer\"}`}>\n        {label && <span className=\"text-[13.5px] font-medium leading-5 text-foreground\">{label}</span>}\n        {description && <span className=\"text-[12.5px] leading-snug text-muted-foreground\">{description}</span>}\n      </label>\n    </div>\n  );\n}\n\nexport default Switch;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/switch. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}