{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"motion-switch","type":"registry:component","title":"Switch (Motion)","description":"Switch with motion: the thumb springs across and stretches while you hold it, follows a drag (over past the middle, a rubber band past an end), and phones buzz as it switches.","dependencies":["motion","radix-ui"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/motion-field.json","https://dashral.com/r/motion-reduced.json","https://dashral.com/r/motion-tokens.json","https://dashral.com/r/field.json","https://dashral.com/r/switch.json"],"files":[{"path":"registry/dashral/motion/switch.tsx","type":"registry:file","target":"components/dashral/motion/switch.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { Switch as SwitchPrimitive } from \"radix-ui\"\nimport { animate, motion, useMotionValue } from \"motion/react\"\nimport { ChoiceField, joinIds } from \"../field\"\nimport type { SwitchProps } from \"../switch\"\nimport { useLocale } from \"../lib/locale\"\nimport { cn } from \"../lib/utils\"\n\nimport { motionMessages } from \"../lib/motion-field\"\nimport { useReducedMotion } from \"../lib/motion-reduced\"\nimport { duration, spring } from \"../lib/motion-tokens\"\n\nexport type { SwitchProps } from \"../switch\"\n\ntype ControlColor = NonNullable<SwitchProps[\"color\"]>\n\n// The track when on, as the kit's Switch has it. Every class is written out in full so Tailwind finds it.\nconst trackColors: Record<ControlColor, string> = {\n  primary: \"data-[state=checked]:bg-primary\",\n  neutral: \"data-[state=checked]:bg-foreground\",\n  info: \"data-[state=checked]:bg-info\",\n  success: \"data-[state=checked]:bg-success\",\n  warning: \"data-[state=checked]:bg-warning\",\n  destructive: \"data-[state=checked]:bg-destructive\",\n  highlight: \"data-[state=checked]:bg-highlight\",\n}\n\n/** How far the thumb travels from off to on (a 36px track, 2px padding, a 16px thumb). */\nconst travel = 16\n\n/** Past an end, a drag gives less and less, and never more than `most` pixels (a rubber band). */\nconst rubber = (distance: number, most = 5) => Math.sign(distance) * most * (1 - 1 / (Math.abs(distance) / most / 2 + 1))\n\n/** A light buzz on phones that have one. */\nconst buzz = (pattern: number | number[]) => {\n  if (typeof navigator !== \"undefined\" && \"vibrate\" in navigator) navigator.vibrate(pattern)\n}\n\n/**\n * The kit's Switch with motion: the same label, description, colors and props. The thumb springs across with a touch\n * of overshoot, and stretches toward where it's going while you hold it, like a finger pushing it, the track giving\n * a little under it. Drag the thumb and it follows: past the middle it goes over when you let go, short of it it\n * springs back, and pulled past an end it holds back like a rubber band. Phones buzz as it switches and as a drag\n * crosses the middle. With reduced motion it just moves.\n */\nexport function Switch({ label, description, error, id, disabled, labelPosition, color = \"primary\", className, classNames, ...props }: SwitchProps) {\n  return (\n    <ChoiceField renderMessages={motionMessages} {...{ label, description, error, id, disabled, labelPosition, classNames }} leading=\"leading-5\">\n      {(control) => (\n        <SwitchControl\n          {...props}\n          {...control}\n          color={color}\n          disabled={disabled}\n          aria-describedby={joinIds(props[\"aria-describedby\"], control[\"aria-describedby\"])}\n          aria-invalid={props[\"aria-invalid\"] ?? control[\"aria-invalid\"]}\n          className={cn(trackColors[color], className, classNames?.switch)}\n        />\n      )}\n    </ChoiceField>\n  )\n}\n\nfunction SwitchControl({\n  className,\n  color,\n  checked,\n  defaultChecked,\n  onCheckedChange,\n  onPointerDown,\n  onClick,\n  ...props\n}: Omit<React.ComponentProps<typeof SwitchPrimitive.Root>, \"color\"> & { color: ControlColor }) {\n  const reduced = useReducedMotion()\n  const [own, setOwn] = React.useState(defaultChecked ?? false)\n  const on = checked ?? own\n  const [held, setHeld] = React.useState(false)\n  const touch = React.useRef(false)\n  // Right to left, \"on\" is the left end.\n  const sign = useLocale().dir === \"rtl\" ? -1 : 1\n\n  // The thumb's one position, in pixels from the off end: every move (a switch, a hold, a drag) goes through it, so\n  // nothing else ever places the thumb. Held, a thumb at the on end steps back by what it grows, to stay in the track.\n  const rest = (state: boolean, isHeld: boolean) => ((state ? travel : 0) - (state && isHeld && !reduced ? 4 : 0)) * sign\n  const x = useMotionValue(rest(on, false))\n  const drag = React.useRef<{ pointer: number; x: number; active: boolean; over: boolean } | null>(null)\n  const dragged = React.useRef(false)\n\n  // To its place whenever the state or the hold changes, unless a finger is moving it.\n  React.useEffect(() => {\n    if (drag.current?.active) return\n    const target = ((on ? travel : 0) - (on && held && !reduced ? 4 : 0)) * sign\n    if (reduced) x.jump(target)\n    else {\n      const controls = animate(x, target, spring.move)\n      return () => controls.stop()\n    }\n  }, [on, held, reduced, sign, x])\n\n  const toggle = (next: boolean) => {\n    if (touch.current) buzz(8)\n    if (checked === undefined) setOwn(next)\n    onCheckedChange?.(next)\n  }\n\n  /** Ends a drag: past the middle it switches (and the thumb springs on from where it is), short of it it springs back. */\n  function finish(state: NonNullable<typeof drag.current>) {\n    drag.current = null\n    setHeld(false)\n    if (!state.active) return\n    // The click that follows a drag isn't a toggle of its own.\n    dragged.current = true\n    if (state.over) toggle(!on)\n    else animate(x, rest(on, false), spring.move)\n  }\n\n  function dragMove(event: React.PointerEvent<HTMLButtonElement>) {\n    const state = drag.current\n    if (!state || state.pointer !== event.pointerId || reduced) return\n    // The button was let go somewhere we didn't hear: end here rather than follow a pointer that isn't pressing.\n    if (event.buttons === 0) return finish(state)\n    const dx = (event.clientX - state.x) * sign\n    if (!state.active) {\n      if (Math.abs(dx) < 3) return\n      state.active = true\n      event.currentTarget.setPointerCapture(event.pointerId)\n    }\n    // Toward the other side it follows freely up to the end; the other way, or past the end, it holds back.\n    const toward = on ? -dx : dx\n    const followed = toward < 0 ? rubber(toward) : toward > travel ? travel + rubber(toward - travel) : toward\n    const over = toward > travel / 2\n    if (over !== state.over) {\n      state.over = over\n      if (touch.current) buzz(4)\n    }\n    x.set(rest(on, true) + (on ? -followed : followed) * sign)\n  }\n\n  function dragEnd(event: React.PointerEvent<HTMLButtonElement>) {\n    const state = drag.current\n    if (state && state.pointer === event.pointerId) finish(state)\n  }\n\n  // Let go anywhere (the pointer may leave the switch before a drag starts): the hold and any drag end.\n  React.useEffect(() => {\n    if (!held) return\n    const release = () => {\n      if (drag.current) finish(drag.current)\n      else setHeld(false)\n    }\n    window.addEventListener(\"pointerup\", release)\n    window.addEventListener(\"pointercancel\", release)\n    return () => {\n      window.removeEventListener(\"pointerup\", release)\n      window.removeEventListener(\"pointercancel\", release)\n    }\n  })\n\n  return (\n    <SwitchPrimitive.Root\n      data-slot=\"switch\"\n      data-color={color}\n      checked={on}\n      data-held={held || undefined}\n      onCheckedChange={toggle}\n      onPointerDown={(event) => {\n        touch.current = event.pointerType === \"touch\"\n        if (!props.disabled && event.button === 0) {\n          setHeld(true)\n          drag.current = { pointer: event.pointerId, x: event.clientX, active: false, over: false }\n        }\n        onPointerDown?.(event)\n      }}\n      onPointerMove={dragMove}\n      onPointerUp={dragEnd}\n      onPointerCancel={dragEnd}\n      onLostPointerCapture={dragEnd}\n      onClick={(event) => {\n        if (dragged.current) {\n          dragged.current = false\n          event.preventDefault()\n        } else if (event.detail === 0) touch.current = false\n        onClick?.(event)\n      }}\n      className={cn(\n        \"peer inline-flex h-5 w-9 shrink-0 touch-none items-center justify-start rounded-full border border-transparent p-0.5 shadow-xs transition-[color,background-color,scale] duration-(--duration-fast) outline-none data-held:scale-95 motion-reduce:data-held:scale-100 focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=unchecked]:bg-input\",\n        className\n      )}\n      {...props}\n    >\n      <SwitchPrimitive.Thumb asChild>\n        <motion.span\n          data-slot=\"switch-thumb\"\n          animate={{ width: held && !reduced ? 20 : 16 }}\n          transition={spring.move}\n          style={{ x, transition: `background-color ${duration.fast}s` }}\n          className={cn(\n            \"pointer-events-none block h-4 shrink-0 rounded-full bg-white shadow-sm\",\n            // A white thumb would vanish on the light track neutral has in dark mode.\n            color === \"neutral\" && on && \"bg-background\"\n          )}\n        />\n      </SwitchPrimitive.Thumb>\n    </SwitchPrimitive.Root>\n  )\n}\n"}],"cssVars":{"theme":{"color-info":"var(--info)","color-info-foreground":"var(--info-foreground)","color-info-text":"var(--info-text)","color-success":"var(--success)","color-success-foreground":"var(--success-foreground)","color-success-text":"var(--success-text)","color-warning":"var(--warning)","color-warning-foreground":"var(--warning-foreground)","color-warning-text":"var(--warning-text)","color-highlight":"var(--highlight)","color-highlight-foreground":"var(--highlight-foreground)","color-highlight-text":"var(--highlight-text)","ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"info":"oklch(0.5 0.134 242.749)","info-foreground":"oklch(0.985 0 0)","info-text":"oklch(0.443 0.11 240.79)","success":"oklch(0.508 0.118 165.612)","success-foreground":"oklch(0.985 0 0)","success-text":"oklch(0.432 0.095 166.913)","warning":"#f59200","warning-foreground":"oklch(0.3 0.07 60)","warning-text":"#964e00","highlight":"#b8008c","highlight-foreground":"oklch(0.985 0 0)","highlight-text":"#a3007b","duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{"info":"oklch(0.685 0.169 237.323)","info-foreground":"oklch(0.2 0 0)","info-text":"oklch(0.746 0.16 232.661)","success":"oklch(0.696 0.17 162.48)","success-foreground":"oklch(0.2 0.04 160)","success-text":"oklch(0.765 0.177 163.223)","warning":"#f59200","warning-foreground":"oklch(0.25 0.06 60)","warning-text":"#ffba42","highlight":"#ff2ecf","highlight-foreground":"oklch(0.2 0 0)","highlight-text":"#ff75df"}}}