{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-switch",
  "title": "Animated Switch",
  "description": "An iOS-quality animated toggle switch with a press-to-stretch knob, drag-to-toggle with flick support, and optional crossfading knob icons",
  "dependencies": [
    "framer-motion"
  ],
  "files": [
    {
      "path": "app/registry/animated-switch/animated-switch.tsx",
      "content": "/**\n * Spectrum UI — AnimatedSwitch\n *\n * An iOS-quality toggle switch. Pressing stretches the knob toward the far\n * side (anchored to the side it currently sits on) and releasing springs it\n * back round; dragging carries the knob across the track and commits to the\n * nearest side on release, and a quick flick commits in the flick direction.\n * The track color crossfades, optional knob icons rotate through a crossfade,\n * and prefers-reduced-motion collapses everything to an instant jump. Works\n * controlled or uncontrolled and announces itself via role=\"switch\".\n *\n * Dependencies: framer-motion, @/lib/utils\n *\n * @example\n * <AnimatedSwitch defaultChecked onCheckedChange={(checked) => save(checked)} />\n */\n\n\"use client\"\n\nimport React, { useCallback, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"framer-motion\"\nimport { cn } from \"@/lib/utils\"\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface AnimatedSwitchProps {\n  /** Controlled checked state. Leave undefined for uncontrolled usage */\n  checked?: boolean\n  /** Initial checked state when uncontrolled. Default false */\n  defaultChecked?: boolean\n  /** Fires with the next checked state whenever a toggle commits */\n  onCheckedChange?: (checked: boolean) => void\n  /** Icon shown inside the knob while on; crossfades with offIcon on toggle */\n  onIcon?: React.ReactNode\n  /** Icon shown inside the knob while off; crossfades with onIcon on toggle */\n  offIcon?: React.ReactNode\n  /** Visual size of the switch. Default \"md\" */\n  size?: \"sm\" | \"md\" | \"lg\"\n  /** Disables pointer and keyboard interaction */\n  disabled?: boolean\n  /** Accessible name of the switch. Default \"Toggle\" */\n  label?: string\n  /** Additional classes merged with the default track styles */\n  className?: string\n}\n\ninterface PointerSample {\n  /** Pointer clientX, px */\n  x: number\n  /** Event timestamp, ms */\n  t: number\n}\n\ninterface GestureState {\n  /** Pointer captured for this gesture */\n  pointerId: number\n  /** clientX where the press started, px */\n  originClientX: number\n  /** Knob x in inner-track coordinates when the press started, px */\n  originKnobX: number\n  /** Whether the press has travelled far enough to count as a drag */\n  dragging: boolean\n  /** Recent samples used for the release-velocity estimate */\n  samples: PointerSample[]\n}\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\n/** Gap between the knob and the track edge on every side, px */\nconst TRACK_PADDING = 2\n/** Horizontal knob growth while pressed — the signature iOS stretch */\nconst STRETCH_FACTOR = 1.35\n/** Pointer travel before a press becomes a drag instead of a click, px */\nconst DRAG_START_DISTANCE = 3\n/** Release velocity that commits a toggle in the flick direction, px/s */\nconst FLICK_VELOCITY = 250\n/** Recent pointer samples kept for the release-velocity estimate */\nconst VELOCITY_SAMPLE_COUNT = 5\n/** Knob icon rotation while crossfading, deg */\nconst ICON_ROTATION = 45\n\n/** Snappy spring for knob travel and the press stretch */\nconst SNAPPY_SPRING = { type: \"spring\", stiffness: 500, damping: 30 } as const\n/** Softer spring for the icon crossfade */\nconst SOFT_SPRING = { type: \"spring\", stiffness: 260, damping: 22 } as const\n\nconst SIZES = {\n  sm: { trackWidth: 32, trackHeight: 18, icon: 8 },\n  md: { trackWidth: 44, trackHeight: 24, icon: 10 },\n  lg: { trackWidth: 56, trackHeight: 30, icon: 12 },\n} as const\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max)\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport function AnimatedSwitch({\n  checked: checkedProp,\n  defaultChecked = false,\n  onCheckedChange,\n  onIcon,\n  offIcon,\n  size = \"md\",\n  disabled = false,\n  label = \"Toggle\",\n  className,\n}: AnimatedSwitchProps) {\n  const shouldReduceMotion = useReducedMotion()\n  const [internalChecked, setInternalChecked] = useState(defaultChecked)\n  const [pressed, setPressed] = useState(false)\n  // Knob x while dragging (inner-track coordinates); null when not dragging\n  const [dragX, setDragX] = useState<number | null>(null)\n\n  const gesture = useRef<GestureState | null>(null)\n  // A drag commits on pointerup, so the click the browser fires right after\n  // must not toggle a second time\n  const suppressClick = useRef(false)\n\n  const checked = checkedProp ?? internalChecked\n  const { trackWidth, trackHeight, icon: iconSize } = SIZES[size]\n\n  const knobSize = trackHeight - TRACK_PADDING * 2\n  const stretchedWidth = Math.round(knobSize * STRETCH_FACTOR)\n  const innerWidth = trackWidth - TRACK_PADDING * 2\n  // Stretch only applies while pressed; reduced motion keeps the knob round\n  const knobWidth = pressed && !shouldReduceMotion ? stretchedWidth : knobSize\n  const maxX = innerWidth - knobWidth\n  // Off rests at the left edge and on hugs the right edge, so x and width\n  // spring in lockstep and the stretch stays anchored to the near side\n  const knobX = dragX !== null ? clamp(dragX, 0, maxX) : checked ? maxX : 0\n\n  const setChecked = useCallback(\n    (next: boolean) => {\n      if (checkedProp === undefined) setInternalChecked(next)\n      onCheckedChange?.(next)\n    },\n    [checkedProp, onCheckedChange],\n  )\n\n  const handleClick = useCallback(() => {\n    if (suppressClick.current) {\n      suppressClick.current = false\n      return\n    }\n    setChecked(!checked)\n  }, [checked, setChecked])\n\n  const handlePointerDown = useCallback(\n    (event: React.PointerEvent<HTMLButtonElement>) => {\n      if (disabled || !event.isPrimary) return\n      suppressClick.current = false\n      event.currentTarget.setPointerCapture(event.pointerId)\n      const width = shouldReduceMotion ? knobSize : stretchedWidth\n      gesture.current = {\n        pointerId: event.pointerId,\n        originClientX: event.clientX,\n        originKnobX: checked ? innerWidth - width : 0,\n        dragging: false,\n        samples: [{ x: event.clientX, t: event.timeStamp }],\n      }\n      setPressed(true)\n    },\n    [checked, disabled, innerWidth, knobSize, shouldReduceMotion, stretchedWidth],\n  )\n\n  const handlePointerMove = useCallback(\n    (event: React.PointerEvent<HTMLButtonElement>) => {\n      const state = gesture.current\n      if (!state || event.pointerId !== state.pointerId) return\n      state.samples.push({ x: event.clientX, t: event.timeStamp })\n      if (state.samples.length > VELOCITY_SAMPLE_COUNT) state.samples.shift()\n      const deltaX = event.clientX - state.originClientX\n      if (!state.dragging && Math.abs(deltaX) < DRAG_START_DISTANCE) return\n      state.dragging = true\n      setDragX(state.originKnobX + deltaX)\n    },\n    [],\n  )\n\n  const endGesture = useCallback(\n    (event: React.PointerEvent<HTMLButtonElement>) => {\n      gesture.current = null\n      if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n        event.currentTarget.releasePointerCapture(event.pointerId)\n      }\n      setPressed(false)\n      setDragX(null)\n    },\n    [],\n  )\n\n  const handlePointerUp = useCallback(\n    (event: React.PointerEvent<HTMLButtonElement>) => {\n      const state = gesture.current\n      if (!state || event.pointerId !== state.pointerId) return\n      if (state.dragging) {\n        suppressClick.current = true\n        const width = shouldReduceMotion ? knobSize : stretchedWidth\n        const knobEnd = clamp(\n          state.originKnobX + (event.clientX - state.originClientX),\n          0,\n          innerWidth - width,\n        )\n        const oldest = state.samples[0]\n        const elapsed = event.timeStamp - oldest.t\n        const velocity =\n          elapsed > 0 ? ((event.clientX - oldest.x) / elapsed) * 1000 : 0\n        // A fast flick wins; otherwise commit to whichever side the knob\n        // center is closest to\n        const next =\n          Math.abs(velocity) >= FLICK_VELOCITY\n            ? velocity > 0\n            : knobEnd + width / 2 > innerWidth / 2\n        if (next !== checked) setChecked(next)\n      }\n      endGesture(event)\n    },\n    [checked, endGesture, innerWidth, knobSize, setChecked, shouldReduceMotion, stretchedWidth],\n  )\n\n  const hasIcons = onIcon != null || offIcon != null\n  const activeIcon = checked ? onIcon : offIcon\n\n  return (\n    <button\n      type=\"button\"\n      role=\"switch\"\n      aria-checked={checked}\n      aria-label={label}\n      disabled={disabled}\n      onClick={handleClick}\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onPointerUp={handlePointerUp}\n      onPointerCancel={endGesture}\n      className={cn(\n        \"relative inline-flex shrink-0 cursor-pointer touch-manipulation select-none items-center rounded-full\",\n        \"transition-colors duration-200 ease-out\",\n        \"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        checked\n          ? \"bg-neutral-900 dark:bg-white\"\n          : \"bg-neutral-200 dark:bg-neutral-700\",\n        className,\n      )}\n      style={{ width: trackWidth, height: trackHeight }}\n    >\n      {/* Invisible hit-area extender: keeps the target at least 24px square */}\n      <span\n        aria-hidden=\"true\"\n        className=\"absolute left-1/2 top-1/2 h-[max(100%,24px)] w-[max(100%,24px)] -translate-x-1/2 -translate-y-1/2\"\n      />\n\n      <motion.span\n        aria-hidden=\"true\"\n        className=\"absolute rounded-full bg-white shadow-xs dark:bg-neutral-900\"\n        style={{ top: TRACK_PADDING, left: TRACK_PADDING, height: knobSize }}\n        initial={false}\n        animate={{ x: knobX, width: knobWidth }}\n        transition={shouldReduceMotion ? { duration: 0 } : SNAPPY_SPRING}\n      >\n        {hasIcons && (\n          <span className=\"pointer-events-none absolute inset-0 flex items-center justify-center text-neutral-600 dark:text-neutral-300\">\n            <AnimatePresence initial={false}>\n              {activeIcon != null && (\n                <motion.span\n                  key={checked ? \"on\" : \"off\"}\n                  className=\"absolute flex items-center justify-center [&_svg]:h-full [&_svg]:w-full\"\n                  style={{\n                    width: iconSize,\n                    height: iconSize,\n                    fontSize: iconSize,\n                    lineHeight: 1,\n                  }}\n                  initial={\n                    shouldReduceMotion\n                      ? { opacity: 0 }\n                      : { opacity: 0, rotate: -ICON_ROTATION, scale: 0.5 }\n                  }\n                  animate={\n                    shouldReduceMotion\n                      ? { opacity: 1 }\n                      : { opacity: 1, rotate: 0, scale: 1 }\n                  }\n                  exit={\n                    shouldReduceMotion\n                      ? { opacity: 0 }\n                      : { opacity: 0, rotate: ICON_ROTATION, scale: 0.5 }\n                  }\n                  transition={shouldReduceMotion ? { duration: 0 } : SOFT_SPRING}\n                >\n                  {activeIcon}\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </span>\n        )}\n      </motion.span>\n    </button>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/animated-switch.tsx"
    }
  ],
  "type": "registry:component"
}
