{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"An OrbitalImageWheel component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type FocusEvent as ReactFocusEvent,\n  type PointerEvent as ReactPointerEvent,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nconst DEFAULT_RADIUS = 160;\nconst DEFAULT_AUTO_ROTATE_SPEED = 12; // degrees per second\nconst ITEM_SIZE = 80; // px, diameter of each thumbnail button\nconst ROTATE_SENSITIVITY = 0.6; // degrees per pixel of horizontal drag\nconst VELOCITY_SMOOTHING = 0.35;\nconst MOMENTUM_FRICTION = 0.94; // velocity decay per ~16ms frame\nconst MOMENTUM_EPSILON = 0.02; // deg/ms below which momentum stops\nconst SNAP_DURATION = 250; // ms\nconst STEP_DURATION = 250; // ms\nconst DRAG_MOVE_THRESHOLD = 4; // px before a pointer gesture counts as a drag\n\nexport interface OrbitalImageWheelItem {\n  alt: string;\n  id: string;\n  image: string;\n  label?: string;\n}\n\nexport interface OrbitalImageWheelProps {\n  /** Forces a specific item to be highlighted, overriding the automatic top-of-circle detection. */\n  activeId?: string;\n  autoRotate?: boolean;\n  /** Degrees per second while auto-rotating. */\n  autoRotateSpeed?: number;\n  className?: string;\n  /** Tilts each thumbnail to face outward from the center, like a ferris wheel gondola. */\n  faceOutward?: boolean;\n  items: OrbitalImageWheelItem[];\n  onRotationChange?: (rotation: number) => void;\n  /** Circle radius in px. */\n  radius?: number;\n  /** Controlled rotation in degrees. */\n  rotation?: number;\n  /** Snap to the nearest item when a drag or momentum settles. */\n  snap?: boolean;\n}\n\nconst normalizeAngle = (angle: number): number => {\n  const wrapped = angle % 360;\n  return wrapped < 0 ? wrapped + 360 : wrapped;\n};\n\nconst shortestDelta = (from: number, to: number): number => {\n  const diff = normalizeAngle(to - from);\n  return diff > 180 ? diff - 360 : diff;\n};\n\nconst easeInOutCubic = (t: number): number =>\n  t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;\n\nexport default function OrbitalImageWheel({\n  activeId,\n  autoRotate = false,\n  autoRotateSpeed = DEFAULT_AUTO_ROTATE_SPEED,\n  className,\n  faceOutward = false,\n  items,\n  onRotationChange,\n  radius = DEFAULT_RADIUS,\n  rotation,\n  snap = false,\n}: OrbitalImageWheelProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const isControlled = rotation !== undefined;\n  const [internalRotation, setInternalRotation] = useState(0);\n  const currentRotation = isControlled\n    ? (rotation as number)\n    : internalRotation;\n\n  const rotationRef = useRef(currentRotation);\n  rotationRef.current = currentRotation;\n  const velocityRef = useRef(0);\n  const activeFrameRef = useRef<number | null>(null);\n  const dragRef = useRef<{\n    lastTime: number;\n    lastX: number;\n    moved: boolean;\n    pointerId: number;\n  } | null>(null);\n  const justDraggedRef = useRef(false);\n\n  const [isHoverDevice, setIsHoverDevice] = useState(false);\n  const [isPaused, setIsPaused] = useState(false);\n  const pauseReasonsRef = useRef({ focus: false, hidden: false, hover: false });\n\n  const itemAngle = items.length > 0 ? 360 / items.length : 0;\n\n  const commitRotation = useCallback(\n    (next: number) => {\n      const normalized = normalizeAngle(next);\n      if (!isControlled) {\n        setInternalRotation(normalized);\n      }\n      onRotationChange?.(normalized);\n    },\n    [isControlled, onRotationChange]\n  );\n\n  const cancelActiveFrame = useCallback(() => {\n    if (activeFrameRef.current !== null) {\n      cancelAnimationFrame(activeFrameRef.current);\n      activeFrameRef.current = null;\n    }\n  }, []);\n\n  const nearestItemAngleTo = useCallback(\n    (value: number): number => {\n      if (itemAngle === 0) {\n        return value;\n      }\n      return Math.round(value / itemAngle) * itemAngle;\n    },\n    [itemAngle]\n  );\n\n  const snapTo = useCallback(\n    (target: number, duration = SNAP_DURATION) => {\n      cancelActiveFrame();\n      if (shouldReduceMotion) {\n        rotationRef.current = normalizeAngle(target);\n        commitRotation(rotationRef.current);\n        return;\n      }\n      const from = rotationRef.current;\n      const delta = shortestDelta(from, target);\n      const start = performance.now();\n      const tick = (time: number) => {\n        const progress =\n          duration <= 0 ? 1 : Math.min((time - start) / duration, 1);\n        const eased = easeInOutCubic(progress);\n        rotationRef.current = normalizeAngle(from + delta * eased);\n        commitRotation(rotationRef.current);\n        if (progress < 1) {\n          activeFrameRef.current = requestAnimationFrame(tick);\n        } else {\n          activeFrameRef.current = null;\n        }\n      };\n      activeFrameRef.current = requestAnimationFrame(tick);\n    },\n    [cancelActiveFrame, commitRotation, shouldReduceMotion]\n  );\n\n  const startMomentum = useCallback(() => {\n    cancelActiveFrame();\n    let velocity = velocityRef.current;\n    let last = performance.now();\n    const tick = (time: number) => {\n      const dt = time - last;\n      last = time;\n      rotationRef.current = normalizeAngle(rotationRef.current + velocity * dt);\n      velocity *= MOMENTUM_FRICTION ** (dt / 16);\n      commitRotation(rotationRef.current);\n      if (Math.abs(velocity) < MOMENTUM_EPSILON) {\n        if (snap) {\n          snapTo(nearestItemAngleTo(rotationRef.current));\n        }\n        activeFrameRef.current = null;\n        return;\n      }\n      activeFrameRef.current = requestAnimationFrame(tick);\n    };\n    activeFrameRef.current = requestAnimationFrame(tick);\n  }, [cancelActiveFrame, commitRotation, nearestItemAngleTo, snap, snapTo]);\n\n  // Detect hover-capable pointer devices before enabling hover-based pause.\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setIsHoverDevice(mediaQuery.matches);\n    update();\n    mediaQuery.addEventListener(\"change\", update);\n    return () => mediaQuery.removeEventListener(\"change\", update);\n  }, []);\n\n  const recomputePause = useCallback(() => {\n    const { focus, hidden, hover } = pauseReasonsRef.current;\n    setIsPaused(focus || hidden || hover);\n  }, []);\n\n  // Pause auto-rotate while the tab is hidden.\n  useEffect(() => {\n    const handleVisibility = () => {\n      pauseReasonsRef.current.hidden = document.hidden;\n      recomputePause();\n    };\n    document.addEventListener(\"visibilitychange\", handleVisibility);\n    return () =>\n      document.removeEventListener(\"visibilitychange\", handleVisibility);\n  }, [recomputePause]);\n\n  // Continuous auto-rotate loop; restarts cleanly whenever paused state changes.\n  useEffect(() => {\n    if (shouldReduceMotion || !autoRotate || isPaused || items.length === 0) {\n      return;\n    }\n    let raf = 0;\n    let last = performance.now();\n    const tick = (time: number) => {\n      const dt = time - last;\n      last = time;\n      rotationRef.current = normalizeAngle(\n        rotationRef.current + (autoRotateSpeed * dt) / 1000\n      );\n      commitRotation(rotationRef.current);\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [\n    autoRotate,\n    autoRotateSpeed,\n    commitRotation,\n    isPaused,\n    items.length,\n    shouldReduceMotion,\n  ]);\n\n  useEffect(() => cancelActiveFrame, [cancelActiveFrame]);\n\n  const handlePointerEnter = useCallback(() => {\n    pauseReasonsRef.current.hover = true;\n    recomputePause();\n  }, [recomputePause]);\n\n  const handlePointerLeave = useCallback(() => {\n    pauseReasonsRef.current.hover = false;\n    recomputePause();\n  }, [recomputePause]);\n\n  const handleFocus = useCallback(() => {\n    pauseReasonsRef.current.focus = true;\n    recomputePause();\n  }, [recomputePause]);\n\n  const handleBlur = useCallback(\n    (event: ReactFocusEvent<HTMLDivElement>) => {\n      if (event.currentTarget.contains(event.relatedTarget)) {\n        return;\n      }\n      pauseReasonsRef.current.focus = false;\n      recomputePause();\n    },\n    [recomputePause]\n  );\n\n  const handlePointerDown = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>) => {\n      cancelActiveFrame();\n      dragRef.current = {\n        lastTime: performance.now(),\n        lastX: event.clientX,\n        moved: false,\n        pointerId: event.pointerId,\n      };\n      event.currentTarget.setPointerCapture(event.pointerId);\n    },\n    [cancelActiveFrame]\n  );\n\n  const handlePointerMove = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>) => {\n      const drag = dragRef.current;\n      if (!drag) {\n        return;\n      }\n      const now = performance.now();\n      const dx = event.clientX - drag.lastX;\n      if (Math.abs(dx) > DRAG_MOVE_THRESHOLD) {\n        drag.moved = true;\n      }\n      const dt = Math.max(now - drag.lastTime, 1);\n      const delta = dx * ROTATE_SENSITIVITY;\n      velocityRef.current =\n        velocityRef.current * (1 - VELOCITY_SMOOTHING) +\n        (delta / dt) * VELOCITY_SMOOTHING;\n      rotationRef.current = normalizeAngle(rotationRef.current + delta);\n      commitRotation(rotationRef.current);\n      drag.lastX = event.clientX;\n      drag.lastTime = now;\n    },\n    [commitRotation]\n  );\n\n  const handlePointerUp = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>) => {\n      const drag = dragRef.current;\n      dragRef.current = null;\n      if (!drag) {\n        return;\n      }\n      event.currentTarget.releasePointerCapture(drag.pointerId);\n      justDraggedRef.current = drag.moved;\n      if (shouldReduceMotion) {\n        velocityRef.current = 0;\n        if (snap) {\n          snapTo(nearestItemAngleTo(rotationRef.current));\n        }\n        return;\n      }\n      startMomentum();\n    },\n    [nearestItemAngleTo, shouldReduceMotion, snap, snapTo, startMomentum]\n  );\n\n  const handleKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLDivElement>) => {\n      if (event.key === \"ArrowRight\") {\n        event.preventDefault();\n        snapTo(\n          rotationRef.current - itemAngle,\n          shouldReduceMotion ? 0 : STEP_DURATION\n        );\n      } else if (event.key === \"ArrowLeft\") {\n        event.preventDefault();\n        snapTo(\n          rotationRef.current + itemAngle,\n          shouldReduceMotion ? 0 : STEP_DURATION\n        );\n      }\n    },\n    [itemAngle, shouldReduceMotion, snapTo]\n  );\n\n  const handleItemActivate = useCallback(\n    (position: number) => {\n      if (justDraggedRef.current) {\n        justDraggedRef.current = false;\n        return;\n      }\n      snapTo(-(position * itemAngle));\n    },\n    [itemAngle, snapTo]\n  );\n\n  const nearestTopId = useMemo(() => {\n    if (items.length === 0) {\n      return;\n    }\n    let bestId = items[0].id;\n    let bestDistance = Number.POSITIVE_INFINITY;\n    for (const [position, item] of items.entries()) {\n      const angle = normalizeAngle(position * itemAngle + currentRotation);\n      const distance = Math.abs(shortestDelta(angle, 0));\n      if (distance < bestDistance) {\n        bestDistance = distance;\n        bestId = item.id;\n      }\n    }\n    return bestId;\n  }, [items, itemAngle, currentRotation]);\n\n  const highlightedId = activeId ?? nearestTopId;\n  const highlightedItem = items.find((item) => item.id === highlightedId);\n  const diameter = radius * 2 + ITEM_SIZE;\n\n  return (\n    // biome-ignore lint/a11y/noNoninteractiveElementInteractions: Interactive draggable widget requires event handlers\n    <div\n      aria-label=\"Orbital image wheel\"\n      className={cn(\"relative mx-auto touch-none select-none\", className)}\n      onBlur={handleBlur}\n      onFocus={handleFocus}\n      onKeyDown={handleKeyDown}\n      onPointerDown={handlePointerDown}\n      onPointerEnter={isHoverDevice ? handlePointerEnter : undefined}\n      onPointerLeave={isHoverDevice ? handlePointerLeave : undefined}\n      onPointerMove={handlePointerMove}\n      onPointerUp={handlePointerUp}\n      role=\"application\"\n      style={{ height: diameter, width: diameter }}\n    >\n      {items.map((item, position) => {\n        const angle = normalizeAngle(position * itemAngle + currentRotation);\n        const angleRad = (angle * Math.PI) / 180;\n        const x = radius * Math.sin(angleRad);\n        const y = -radius * Math.cos(angleRad);\n        const isActive = highlightedId === item.id;\n\n        return (\n          <button\n            aria-current={isActive ? \"true\" : undefined}\n            aria-label={item.label ?? item.alt}\n            className={cn(\n              \"absolute top-1/2 left-1/2 flex items-center justify-center overflow-hidden rounded-full border-2 border-transparent shadow-md transition-[border-color,box-shadow] duration-200 focus-visible:outline-2 focus-visible:outline-brand focus-visible:outline-offset-2\",\n              isActive && \"border-brand shadow-lg\"\n            )}\n            key={item.id}\n            onClick={() => handleItemActivate(position)}\n            style={{\n              height: ITEM_SIZE,\n              transform: `translate(-50%, -50%) translate(${x}px, ${y}px) rotate(${\n                faceOutward ? angle : 0\n              }deg)`,\n              width: ITEM_SIZE,\n              zIndex: Math.round(y),\n            }}\n            type=\"button\"\n          >\n            <img\n              alt={item.alt}\n              className=\"h-full w-full object-cover\"\n              draggable={false}\n              src={item.image}\n            />\n          </button>\n        );\n      })}\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {highlightedItem\n          ? `${highlightedItem.label ?? highlightedItem.alt} selected`\n          : null}\n      </span>\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/orbital-image-wheel/index.tsx","type":"registry:ui"}],"name":"orbital-image-wheel","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Orbital Image Wheel","type":"registry:ui"}