{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "switch", "title": "Switch (Base UI)", "description": "Animated toggle switch with spring-based thumb animation. Base UI flavor.", "dependencies": [ "framer-motion", "@base-ui/react" ], "registryDependencies": [ "https://zeron-ui.vercel.app/r/surfaces.json", "utils", "https://zeron-ui.vercel.app/r/springs.json" ], "files": [ { "path": "src/components/ui/switch.tsx", "content": "\"use client\";\n\nimport {\n forwardRef,\n useRef,\n useState,\n useEffect,\n useCallback,\n useId,\n type HTMLAttributes,\n} from \"react\";\nimport { motion, useMotionValue, animate, type Transition } from \"framer-motion\";\nimport { Switch as SwitchPrimitive } from \"@base-ui/react/switch\";\nimport { cn } from \"@/lib/utils\";\nimport { spring } from \"@/lib/springs\";\n\ninterface SwitchProps extends HTMLAttributes {\n label: string;\n checked: boolean;\n onToggle: () => void;\n disabled?: boolean;\n thumbTransition?: Transition;\n}\n\nconst TRACK_WIDTH = 34;\nconst TRACK_HEIGHT = 20;\nconst THUMB_SIZE = 16;\nconst THUMB_OFFSET = 2;\nconst THUMB_TRAVEL = TRACK_WIDTH - THUMB_SIZE - THUMB_OFFSET * 2;\nconst PILL_EXTEND = 2;\nconst PRESS_EXTEND = 4;\nconst PRESS_SHRINK = 4;\nconst DRAG_DEAD_ZONE = 2;\n\nconst Switch = forwardRef(\n ({ label, checked, onToggle, disabled = false, thumbTransition, className, ...props }, ref) => {\n const labelId = useId();\n const hasMounted = useRef(false);\n const [hovered, setHovered] = useState(false);\n const [pressed, setPressed] = useState(false);\n\n const dragging = useRef(false);\n const didDrag = useRef(false);\n const pointerStart = useRef<{\n clientX: number;\n originX: number;\n } | null>(null);\n\n const motionX = useMotionValue(\n checked ? THUMB_OFFSET + THUMB_TRAVEL : THUMB_OFFSET\n );\n\n useEffect(() => {\n hasMounted.current = true;\n }, []);\n\n const thumbWidth = pressed\n ? THUMB_SIZE + PRESS_EXTEND\n : hovered\n ? THUMB_SIZE + PILL_EXTEND\n : THUMB_SIZE;\n const thumbHeight = pressed ? THUMB_SIZE - PRESS_SHRINK : THUMB_SIZE;\n const thumbY = pressed ? THUMB_OFFSET + PRESS_SHRINK / 2 : THUMB_OFFSET;\n const extraWidth = thumbWidth - THUMB_SIZE;\n const thumbX = checked\n ? THUMB_OFFSET + THUMB_TRAVEL - extraWidth\n : THUMB_OFFSET;\n\n useEffect(() => {\n if (dragging.current) return;\n if (!hasMounted.current) {\n motionX.set(thumbX);\n } else {\n animate(motionX, thumbX, thumbTransition ?? spring.moderate);\n }\n }, [thumbX, motionX, thumbTransition]);\n\n const handlePointerDown = useCallback(\n (e: React.PointerEvent) => {\n if (disabled) return;\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n setPressed(true);\n dragging.current = false;\n didDrag.current = false;\n pointerStart.current = {\n clientX: e.clientX,\n originX: motionX.get(),\n };\n (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);\n },\n [disabled, motionX]\n );\n\n const handlePointerMove = useCallback(\n (e: React.PointerEvent) => {\n if (!pointerStart.current) return;\n const delta = e.clientX - pointerStart.current.clientX;\n\n if (!dragging.current) {\n if (Math.abs(delta) < DRAG_DEAD_ZONE) return;\n dragging.current = true;\n }\n\n const dragMin = THUMB_OFFSET;\n const pressedThumbWidth = THUMB_SIZE + PRESS_EXTEND;\n const dragMax = TRACK_WIDTH - THUMB_OFFSET - pressedThumbWidth;\n const rawX = pointerStart.current.originX + delta;\n motionX.set(Math.max(dragMin, Math.min(dragMax, rawX)));\n },\n [motionX]\n );\n\n const handlePointerUp = useCallback(\n () => {\n if (!pointerStart.current) return;\n setPressed(false);\n\n if (dragging.current) {\n didDrag.current = true;\n dragging.current = false;\n\n const currentX = motionX.get();\n const dragMin = THUMB_OFFSET;\n const pressedThumbWidth = THUMB_SIZE + PRESS_EXTEND;\n const dragMax = TRACK_WIDTH - THUMB_OFFSET - pressedThumbWidth;\n const midpoint = (dragMin + dragMax) / 2;\n\n const shouldBeOn = currentX > midpoint;\n\n if (shouldBeOn !== checked) {\n onToggle();\n } else {\n const snapTarget = checked\n ? THUMB_OFFSET + THUMB_TRAVEL\n : THUMB_OFFSET;\n animate(motionX, snapTarget, thumbTransition ?? spring.moderate);\n }\n\n requestAnimationFrame(() => {\n didDrag.current = false;\n });\n }\n\n pointerStart.current = null;\n },\n [checked, onToggle, motionX, thumbTransition]\n );\n\n const handlePointerCancel = useCallback(\n () => {\n if (!pointerStart.current) return;\n setPressed(false);\n\n if (dragging.current) {\n dragging.current = false;\n const snapTarget = checked\n ? THUMB_OFFSET + THUMB_TRAVEL\n : THUMB_OFFSET;\n animate(motionX, snapTarget, thumbTransition ?? spring.moderate);\n }\n\n pointerStart.current = null;\n },\n [checked, motionX, thumbTransition]\n );\n\n return (\n {\n if (e.pointerType === \"mouse\") setHovered(true);\n }}\n onPointerLeave={() => setHovered(false)}\n onPointerDown={handlePointerDown}\n onPointerMove={handlePointerMove}\n onPointerUp={handlePointerUp}\n onPointerCancel={handlePointerCancel}\n onClick={() => {\n if (disabled || didDrag.current) return;\n onToggle();\n }}\n {...props}\n >\n {/* Switch */}\n void for our onToggle.\n onCheckedChange={() => {\n if (didDrag.current) return;\n onToggle();\n }}\n disabled={disabled}\n tabIndex={0}\n className={cn(\n \"relative shrink-0 rounded-full border-[0.5px] border-border outline-none cursor-pointer\",\n \"transition-colors duration-fast\",\n \"focus-visible:ring-1 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-base\"\n )}\n style={{\n width: TRACK_WIDTH,\n height: TRACK_HEIGHT,\n backgroundColor: checked\n ? hovered ? \"var(--brand-hover)\" : \"var(--brand)\"\n : hovered\n ? \"var(--secondary-action-hover)\"\n : \"var(--secondary-action)\",\n }}\n onClick={(e) => e.stopPropagation()}\n >\n {\n const {\n style: baseStyle,\n onDrag: _onDrag,\n onDragStart: _onDragStart,\n onDragEnd: _onDragEnd,\n onAnimationStart: _onAnimationStart,\n onAnimationEnd: _onAnimationEnd,\n onAnimationIteration: _onAnimationIteration,\n ...rest\n } = props as React.HTMLAttributes;\n return (\n \n );\n }}\n />\n \n\n {/* Label */}\n \n {label}\n \n \n );\n }\n);\n\nSwitch.displayName = \"Switch\";\n\nexport { Switch };\nexport type { SwitchProps };\n", "type": "registry:ui", "target": "components/ui/switch.tsx" } ], "type": "registry:ui" }