{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-switch",
  "type": "registry:ui",
  "title": "Animated Switch",
  "description": "An interactive iOS-style squish switch built on Base UI Switch primitives, with interruptible press feedback and a shared feedback spring.",
  "files": [
    {
      "path": "registry/sonaui/animated-switch/animated-switch.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { Switch } from \"@base-ui/react/switch\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n} from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\n\nimport { motionTransition } from \"@/lib/sona-motion\";\nimport { cn } from \"@/lib/sona-utils\";\n\nexport interface AnimatedSwitchProps\n  extends Omit<\n    Switch.Root.Props,\n    \"checked\" | \"defaultChecked\" | \"onCheckedChange\" | \"className\"\n  > {\n  /** Controlled checked state. */\n  checked?: boolean;\n  /** Initial checked state for uncontrolled usage. @default false */\n  defaultChecked?: boolean;\n  /** Callback fired when the checked state changes. */\n  onCheckedChange?: (checked: boolean) => void;\n  /** Whether the switch is disabled. @default false */\n  disabled?: boolean;\n  /**\n   * Whether the switch is in an error state and cannot be changed. @default false\n   * The switch remains focusable so assistive technology can announce its invalid state.\n   */\n  error?: boolean;\n  /** The size of the switch. @default \"md\" */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Whether the thumb can be dragged between states. @default true */\n  enableDrag?: boolean;\n  /** Additional classes for the switch track. */\n  className?: string;\n}\n\nconst PRESS_SCALE_X = 1.14;\nconst BLOCKED_FEEDBACK = [0, -3, 3, -2, 2, 0];\nconst BLOCKED_FEEDBACK_TIMES = [0, 0.2, 0.45, 0.65, 0.82, 1];\n\nconst sizeTokens = {\n  sm: {\n    track: \"h-[16px] w-[36px] p-[1.5px]\",\n    thumb: \"h-[13px] w-[21px]\",\n    surfaceWidth: 36,\n    surfacePadding: 1.5,\n    indicatorWidth: 21,\n    xTranslate: 36 - 1.5 * 2 - 21,\n  },\n  md: {\n    track: \"h-6 w-[54px] p-0.5\",\n    thumb: \"h-5 w-8\",\n    surfaceWidth: 54,\n    surfacePadding: 2,\n    indicatorWidth: 32,\n    xTranslate: 54 - 2 * 2 - 32,\n  },\n  lg: {\n    track: \"h-9 w-20 p-[3px]\",\n    thumb: \"h-[30px] w-[47px]\",\n    surfaceWidth: 80,\n    surfacePadding: 3,\n    indicatorWidth: 47,\n    xTranslate: 80 - 3 * 2 - 47,\n  },\n};\n\nexport default function AnimatedSwitch({\n  checked,\n  defaultChecked = false,\n  onCheckedChange,\n  disabled = false,\n  error = false,\n  size = \"md\",\n  enableDrag = true,\n  className,\n  onClickCapture,\n  onPointerDownCapture,\n  onPointerUpCapture,\n  onPointerCancelCapture,\n  onLostPointerCapture,\n  onPointerMoveCapture,\n  onKeyDownCapture,\n  ...props\n}: AnimatedSwitchProps) {\n  const sizes = sizeTokens[size];\n  const shouldReduceMotion = useReducedMotion();\n  const [isPressing, setIsPressing] = useState(false);\n  const [isDragging, setIsDragging] = useState(false);\n  const [visualChecked, setVisualChecked] = useState(defaultChecked);\n  const dragStartRef = useRef<number | null>(null);\n  const dragXRef = useRef<number | null>(null);\n  const didDragRef = useRef(false);\n  const suppressClickRef = useRef(false);\n  const blockedInteractionRef = useRef<\"pointer\" | \"keyboard\" | null>(null);\n  const thumbRef = useRef<HTMLSpanElement>(null);\n  const thumbAnimationRef = useRef<ReturnType<typeof animate> | null>(null);\n  const blockedFeedbackAnimationRef = useRef<ReturnType<typeof animate> | null>(\n    null,\n  );\n  const thumbX = useMotionValue(defaultChecked ? sizes.xTranslate : 0);\n  const blockedFeedbackX = useMotionValue(0);\n  const resolvedChecked = checked ?? visualChecked;\n  const restingX = resolvedChecked ? sizes.xTranslate : 0;\n  const pressedInset = (sizes.indicatorWidth * (PRESS_SCALE_X - 1)) / 2;\n  const accessibleLabel =\n    props[\"aria-label\"] ?? (props[\"aria-labelledby\"] ? undefined : \"Toggle\");\n\n  useEffect(() => {\n    thumbAnimationRef.current?.stop();\n    if (dragStartRef.current !== null || shouldReduceMotion) {\n      if (shouldReduceMotion) thumbX.set(restingX);\n      return;\n    }\n\n    thumbAnimationRef.current = animate(\n      thumbX,\n      restingX,\n      motionTransition.feedback,\n    );\n  }, [restingX, shouldReduceMotion, thumbX]);\n\n  useEffect(\n    () => () => {\n      thumbAnimationRef.current?.stop();\n      blockedFeedbackAnimationRef.current?.stop();\n    },\n    [],\n  );\n\n  const playBlockedFeedback = () => {\n    if (shouldReduceMotion) return;\n\n    blockedFeedbackAnimationRef.current?.stop();\n    blockedFeedbackAnimationRef.current = animate(\n      blockedFeedbackX,\n      BLOCKED_FEEDBACK,\n      {\n        duration: 0.22,\n        ease: [0.22, 1, 0.36, 1],\n        times: BLOCKED_FEEDBACK_TIMES,\n      },\n    );\n  };\n\n  const resetPointerState = () => {\n    dragStartRef.current = null;\n    dragXRef.current = null;\n    didDragRef.current = false;\n    setIsPressing(false);\n    setIsDragging(false);\n  };\n\n  return (\n    <motion.span\n      className=\"inline-flex\"\n      style={{ x: blockedFeedbackX }}\n      onPointerDownCapture={() => {\n        if (disabled) playBlockedFeedback();\n      }}\n    >\n      <Switch.Root\n        {...props}\n        checked={resolvedChecked}\n        disabled={disabled}\n        aria-disabled={error || undefined}\n        aria-invalid={error || undefined}\n        aria-label={accessibleLabel}\n        onCheckedChange={(nextChecked) => {\n          if (error) {\n            if (blockedInteractionRef.current !== \"keyboard\") {\n              playBlockedFeedback();\n            }\n            blockedInteractionRef.current = null;\n            return;\n          }\n          setVisualChecked(nextChecked);\n          onCheckedChange?.(nextChecked);\n        }}\n        onClickCapture={(event) => {\n          onClickCapture?.(event);\n          if (!suppressClickRef.current) return;\n\n          suppressClickRef.current = false;\n          event.preventDefault();\n          event.stopPropagation();\n        }}\n        onPointerDownCapture={(event) => {\n          onPointerDownCapture?.(event);\n          if (event.button !== 0 || disabled) return;\n          if (error) {\n            blockedInteractionRef.current = \"pointer\";\n            playBlockedFeedback();\n            return;\n          }\n          event.currentTarget.setPointerCapture(event.pointerId);\n          setIsPressing(true);\n\n          if (\n            !enableDrag ||\n            !thumbRef.current?.contains(event.target as Node)\n          ) {\n            return;\n          }\n\n          thumbAnimationRef.current?.stop();\n          dragStartRef.current = event.clientX;\n        }}\n        onPointerMoveCapture={(event) => {\n          onPointerMoveCapture?.(event);\n          if (dragStartRef.current === null) return;\n\n          const offset = event.clientX - dragStartRef.current;\n          if (Math.abs(offset) > 3 && !didDragRef.current) {\n            didDragRef.current = true;\n            setIsDragging(true);\n          }\n          if (!didDragRef.current) return;\n\n          const rawDragX = Math.min(\n            sizes.xTranslate,\n            Math.max(0, restingX + offset),\n          );\n          const nextDragX = Math.min(\n            sizes.xTranslate - pressedInset,\n            Math.max(pressedInset, rawDragX),\n          );\n\n          dragXRef.current = nextDragX;\n          thumbX.set(nextDragX);\n        }}\n        onPointerUpCapture={(event) => {\n          onPointerUpCapture?.(event);\n          if (didDragRef.current) {\n            const nextChecked =\n              (dragXRef.current ?? restingX) >= sizes.xTranslate / 2;\n            suppressClickRef.current = nextChecked === resolvedChecked;\n            if (nextChecked === resolvedChecked) {\n              thumbAnimationRef.current?.stop();\n              if (shouldReduceMotion) {\n                thumbX.set(restingX);\n              } else {\n                thumbAnimationRef.current = animate(\n                  thumbX,\n                  restingX,\n                  motionTransition.feedback,\n                );\n              }\n            }\n          }\n          resetPointerState();\n        }}\n        onPointerCancelCapture={(event) => {\n          onPointerCancelCapture?.(event);\n          thumbAnimationRef.current?.stop();\n          if (shouldReduceMotion) {\n            thumbX.set(restingX);\n          } else {\n            thumbAnimationRef.current = animate(\n              thumbX,\n              restingX,\n              motionTransition.feedback,\n            );\n          }\n          resetPointerState();\n        }}\n        onLostPointerCapture={(event) => {\n          onLostPointerCapture?.(event);\n          if (dragStartRef.current !== null) {\n            thumbAnimationRef.current?.stop();\n            if (shouldReduceMotion) {\n              thumbX.set(restingX);\n            } else {\n              thumbAnimationRef.current = animate(\n                thumbX,\n                restingX,\n                motionTransition.feedback,\n              );\n            }\n          }\n          resetPointerState();\n        }}\n        onKeyDownCapture={(event) => {\n          onKeyDownCapture?.(event);\n          if (error && (event.key === \" \" || event.key === \"Enter\")) {\n            blockedInteractionRef.current = \"keyboard\";\n          }\n        }}\n        className={cn(\n          \"relative inline-flex shrink-0 touch-pan-y cursor-pointer items-center rounded-full border-none\",\n          \"transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2\",\n          \"focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          sizes.track,\n          \"data-[checked]:bg-foreground data-[unchecked]:bg-foreground/20\",\n          \"[&[aria-invalid=true]]:bg-destructive/20 [&[aria-invalid=true]]:ring-1 [&[aria-invalid=true]]:ring-destructive\",\n          \"[&[aria-disabled=true]]:cursor-not-allowed\",\n          \"disabled:cursor-not-allowed disabled:opacity-50\",\n          className,\n        )}\n      >\n        <Switch.Thumb\n          className={cn(\n            \"block rounded-full bg-background shadow-lg ring-0\",\n            sizes.thumb,\n          )}\n          render={\n            <motion.span\n              ref={thumbRef}\n              style={{\n                x: thumbX,\n                transformOrigin: \"center center\",\n              }}\n              animate={{\n                scaleX: isDragging && !shouldReduceMotion ? PRESS_SCALE_X : 1,\n                scaleY: isPressing && !shouldReduceMotion ? 0.94 : 1,\n              }}\n              transition={\n                shouldReduceMotion\n                  ? motionTransition.instant\n                  : motionTransition.feedback\n              }\n            />\n          }\n        />\n      </Switch.Root>\n    </motion.span>\n  );\n}\n",
      "target": "components/ui/animated-switch/animated-switch.tsx"
    }
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "@sona-ui/sona-utils",
    "@sona-ui/sona-motion"
  ]
}