{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "orbital-image-wheel",
  "title": "Orbital Image Wheel",
  "description": "Scroll-driven half-wheel image layout powered by GSAP ScrollTrigger, with cinematic blur/saturation/brightness focus and centered captions.",
  "dependencies": [
    "gsap"
  ],
  "registryDependencies": [
    "@unlumen-ui/motion-subtitle"
  ],
  "files": [
    {
      "path": "registry/components/unlumen/orbital-image-wheel/index.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type RefObject,\n} from \"react\";\nimport gsap from \"gsap\";\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\";\nimport { cn } from \"@/lib/utils\";\nimport { MotionSubtitle } from \"@/components/unlumen-ui/motion-subtitle\";\n\ngsap.registerPlugin(ScrollTrigger);\n\nexport interface OrbitalImageWheelImage {\n  src: string;\n  alt?: string;\n  label?: string;\n  subtitle?: string;\n}\n\nexport interface OrbitalImageWheelProps {\n  /** Images displayed around the wheel. */\n  images: OrbitalImageWheelImage[];\n  /** Number of full wheel turns during the scroll range. @default 4 */\n  turns?: number;\n  /** Maximum blur amount (px) away from the focus zone. @default 4 */\n  blur?: number;\n  /** Minimum brightness (%) away from focus. @default 40 */\n  dim?: number;\n  /** Extra brightness boost (%) around the active card. @default 30 */\n  brightnessBoost?: number;\n  /** Multiplier for out-of-focus darkening intensity. @default 1.05 */\n  darknessStrength?: number;\n  /** Minimum saturation (%) away from focus. @default 55 */\n  minSaturation?: number;\n  /** Multiplier for out-of-focus desaturation intensity. @default 0.6 */\n  saturationStrength?: number;\n  /** Focus zone width as normalized angular range. @default 0.34 */\n  focusSpread?: number;\n  /** Scale reduction amount away from focus. @default 0.06 */\n  scaleEffect?: number;\n  /** Scroll sensitivity multiplier. Lower values require longer scrolling. @default 0.7 */\n  scrollSensitivity?: number;\n  /** Card width in pixels. @default 220 */\n  itemWidth?: number;\n  /** Card height in pixels. @default 300 */\n  itemHeight?: number;\n  /** Optional fixed wheel diameter in pixels. Defaults to a responsive value based on viewport width. */\n  wheelSize?: number;\n  /** How much of the wheel sits below the viewport (0..1). `0.5` keeps only the top half visible. @default 0.75 */\n  cropRatio?: number;\n  /** Scroll section height in viewport units. @default 330 */\n  scrollLength?: number;\n  /** Bottom offset of the caption block in viewport units. @default 8 */\n  captionOffset?: number;\n  /** Show or hide the centered caption. @default true */\n  showCaption?: boolean;\n  /** Subtitle animation direction. @default \"top\" */\n  subtitleDirection?: \"top\" | \"bottom\";\n  /** Subtitle animation speed multiplier. @default 1 */\n  subtitleSpeed?: number;\n  /** Delay between subtitle character reveals in seconds. @default 0.018 */\n  subtitleStagger?: number;\n  /** Optional scrollable container element used as the animation scroller. */\n  scrollContainerRef?: RefObject<HTMLElement | null>;\n  /** Additional class name on the root element. */\n  className?: string;\n}\n\nconst DEFAULT_TURNS = 4;\nconst DEFAULT_BLUR = 4;\nconst DEFAULT_DIM = 40;\nconst DEFAULT_BRIGHTNESS_BOOST = 30;\nconst DEFAULT_DARKNESS_STRENGTH = 1.05;\nconst DEFAULT_MIN_SATURATION = 55;\nconst DEFAULT_SATURATION_STRENGTH = 0.6;\nconst DEFAULT_FOCUS_SPREAD = 0.34;\nconst DEFAULT_SCALE_EFFECT = 0.06;\nconst DEFAULT_SCROLL_SENSITIVITY = 0.7;\nconst DEFAULT_ITEM_WIDTH = 220;\nconst DEFAULT_ITEM_HEIGHT = 300;\nconst DEFAULT_SCROLL_LENGTH = 330;\nconst DEFAULT_CROP_RATIO = 0.75;\nconst DEFAULT_CAPTION_OFFSET = 15;\nconst DEFAULT_SUBTITLE_DIRECTION = \"top\";\nconst DEFAULT_SUBTITLE_SPEED = 1;\nconst DEFAULT_SUBTITLE_STAGGER = 0.018;\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value));\n}\n\nfunction shortestAngleDistance(a: number, b: number) {\n  const full = Math.PI * 2;\n  const raw = ((a - b + Math.PI) % full) - Math.PI;\n  const normalized = raw < -Math.PI ? raw + full : raw;\n  return Math.abs(normalized);\n}\n\nfunction applyScrollSensitivity(progress: number, sensitivity: number) {\n  const safeSensitivity = clamp(sensitivity, 0.25, 1.6);\n  const exponent = 1 / safeSensitivity;\n  return Math.pow(clamp(progress, 0, 1), exponent);\n}\n\nfunction getFocusedImageIndexWithHysteresis(\n  progress: number,\n  total: number,\n  turns: number,\n  currentIndex: number,\n  hysteresis = 0.18,\n) {\n  if (total <= 0 || turns <= 0) return 0;\n\n  const phaseRaw = total * (0.25 + progress * turns);\n  const phase = ((phaseRaw % total) + total) % total;\n\n  if (currentIndex < 0) {\n    return Math.round(phase) % total;\n  }\n\n  let next = currentIndex;\n  let delta = phase - next;\n\n  if (delta > total / 2) delta -= total;\n  if (delta < -total / 2) delta += total;\n\n  const threshold = 0.5 + clamp(hysteresis, 0, 0.35);\n\n  while (delta > threshold) {\n    next = (next + 1) % total;\n    delta -= 1;\n  }\n\n  while (delta < -threshold) {\n    next = (next - 1 + total) % total;\n    delta += 1;\n  }\n\n  return next;\n}\n\nfunction getSnapProgressForIndex(\n  index: number,\n  total: number,\n  turns: number,\n  currentProgress: number,\n) {\n  if (total <= 0 || turns <= 0) return clamp(currentProgress, 0, 1);\n\n  const safeIndex = ((index % total) + total) % total;\n  const minCycle = Math.floor(-turns - 2);\n  const maxCycle = Math.ceil(turns + 2);\n  let nearest = clamp(currentProgress, 0, 1);\n  let minDistance = Number.POSITIVE_INFINITY;\n\n  for (let cycle = minCycle; cycle <= maxCycle; cycle += 1) {\n    const progress = (safeIndex / total - 0.25 - cycle) / turns;\n    if (progress < 0 || progress > 1) continue;\n\n    const distance = Math.abs(progress - currentProgress);\n    if (distance < minDistance) {\n      minDistance = distance;\n      nearest = progress;\n    }\n  }\n\n  if (!Number.isFinite(minDistance)) {\n    return clamp((safeIndex / total - 0.25) / turns, 0, 1);\n  }\n\n  return nearest;\n}\n\nfunction useViewportWidth(viewportRef: RefObject<HTMLDivElement | null>) {\n  const [width, setWidth] = useState(1200);\n\n  useEffect(() => {\n    const viewport = viewportRef.current;\n    if (!viewport) return;\n\n    const update = () => setWidth(viewport.clientWidth || 1200);\n    update();\n\n    const observer = new ResizeObserver(update);\n    observer.observe(viewport);\n\n    return () => observer.disconnect();\n  }, [viewportRef]);\n\n  return width;\n}\n\nexport function OrbitalImageWheel({\n  images,\n  turns = DEFAULT_TURNS,\n  blur = DEFAULT_BLUR,\n  dim = DEFAULT_DIM,\n  brightnessBoost = DEFAULT_BRIGHTNESS_BOOST,\n  darknessStrength = DEFAULT_DARKNESS_STRENGTH,\n  minSaturation = DEFAULT_MIN_SATURATION,\n  saturationStrength = DEFAULT_SATURATION_STRENGTH,\n  focusSpread = DEFAULT_FOCUS_SPREAD,\n  scaleEffect = DEFAULT_SCALE_EFFECT,\n  scrollSensitivity = DEFAULT_SCROLL_SENSITIVITY,\n  itemWidth = DEFAULT_ITEM_WIDTH,\n  itemHeight = DEFAULT_ITEM_HEIGHT,\n  wheelSize,\n  cropRatio = DEFAULT_CROP_RATIO,\n  scrollLength = DEFAULT_SCROLL_LENGTH,\n  captionOffset = DEFAULT_CAPTION_OFFSET,\n  showCaption = true,\n  subtitleDirection = DEFAULT_SUBTITLE_DIRECTION,\n  subtitleSpeed = DEFAULT_SUBTITLE_SPEED,\n  subtitleStagger = DEFAULT_SUBTITLE_STAGGER,\n  scrollContainerRef,\n  className,\n}: OrbitalImageWheelProps) {\n  const sectionRef = useRef<HTMLElement>(null);\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const wheelRef = useRef<HTMLDivElement>(null);\n  const wheelScrollTriggerRef = useRef<ScrollTrigger | null>(null);\n  const titleClickTweenRef = useRef<gsap.core.Tween | null>(null);\n  const titleViewportRef = useRef<HTMLDivElement>(null);\n  const titleTrackRef = useRef<HTMLDivElement>(null);\n  const titleStartSpacerRef = useRef<HTMLSpanElement>(null);\n  const titleEndSpacerRef = useRef<HTMLSpanElement>(null);\n  const titleTrackXToRef = useRef<((value: number) => void) | null>(null);\n  const [activeIndex, setActiveIndex] = useState(0);\n\n  const viewportWidth = useViewportWidth(viewportRef);\n\n  const boundedTurns = clamp(turns, 0.2, 4);\n  const boundedBlur = clamp(blur, 0, 36);\n  const boundedDim = clamp(dim, 0, 100);\n  const boundedBrightnessBoost = clamp(brightnessBoost, 0, 120);\n  const boundedDarknessStrength = clamp(darknessStrength, 0.2, 3);\n  const boundedMinSaturation = clamp(minSaturation, 0, 100);\n  const boundedSaturationStrength = clamp(saturationStrength, 0.2, 3);\n  const boundedFocusSpread = clamp(focusSpread, 0.08, 0.8);\n  const boundedScaleEffect = clamp(scaleEffect, 0, 0.3);\n  const boundedScrollSensitivity = clamp(scrollSensitivity, 0.25, 1.6);\n  const boundedItemWidth = clamp(itemWidth, 140, 520);\n  const boundedItemHeight = clamp(itemHeight, 180, 620);\n  const boundedCropRatio = clamp(cropRatio, 0.2, 0.8);\n  const boundedScrollLength = clamp(scrollLength, 180, 700);\n  const boundedCaptionOffset = clamp(captionOffset, 2, 22);\n  const boundedSubtitleSpeed = clamp(subtitleSpeed, 0.3, 3);\n  const boundedSubtitleStagger = clamp(subtitleStagger, 0, 0.08);\n  const boundedSubtitleDirection =\n    subtitleDirection === \"bottom\" ? \"bottom\" : \"top\";\n\n  const responsiveWheelSize = clamp(viewportWidth * 1.65, 900, 2400);\n  const boundedWheelSize = clamp(wheelSize ?? responsiveWheelSize, 700, 2600);\n  const radius = boundedWheelSize / 2;\n  const titleLabels = useMemo(\n    () => images.map((img, i) => img.label ?? img.alt ?? `Image ${i + 1}`),\n    [images],\n  );\n  const titleTrackLabels = useMemo(() => titleLabels, [titleLabels]);\n  const activeTitleTrackIndex = Math.max(\n    0,\n    Math.min(activeIndex, titleTrackLabels.length - 1),\n  );\n\n  const handleTitleClick = useCallback(\n    (index: number) => {\n      const trigger = wheelScrollTriggerRef.current;\n      if (!trigger || images.length === 0) return;\n\n      const currentProgress = clamp(trigger.progress, 0, 1);\n      const targetProgress = getSnapProgressForIndex(\n        index,\n        images.length,\n        boundedTurns,\n        currentProgress,\n      );\n\n      const scrollStart = trigger.start;\n      const scrollEnd = trigger.end;\n      const scrollRange = scrollEnd - scrollStart;\n      if (scrollRange <= 0) return;\n\n      const fromScroll = scrollStart + currentProgress * scrollRange;\n      const toScroll = scrollStart + targetProgress * scrollRange;\n\n      setActiveIndex(index);\n      titleClickTweenRef.current?.kill();\n\n      const proxy = { scroll: fromScroll };\n      titleClickTweenRef.current = gsap.to(proxy, {\n        scroll: toScroll,\n        duration: 0.58,\n        ease: \"power3.out\",\n        overwrite: true,\n        onUpdate: () => {\n          trigger.scroll(proxy.scroll);\n        },\n        onComplete: () => {\n          setActiveIndex(index);\n        },\n      });\n    },\n    [images.length, boundedTurns],\n  );\n\n  useEffect(() => {\n    const section = sectionRef.current;\n    const wheel = wheelRef.current;\n    if (!section || !wheel || images.length === 0) return;\n\n    let previousActive = -1;\n\n    const context = gsap.context(() => {\n      const cards = Array.from(\n        wheel.querySelectorAll<HTMLElement>(\".oiw-item\"),\n      );\n      if (cards.length === 0) return;\n\n      const topAnchor = -Math.PI / 2;\n      const focusArc = Math.PI * boundedFocusSpread;\n\n      const applyState = (rawProgress: number) => {\n        const p = applyScrollSensitivity(rawProgress, boundedScrollSensitivity);\n        const rotation = -p * boundedTurns * Math.PI * 2;\n        const focusedIndex = getFocusedImageIndexWithHysteresis(\n          p,\n          cards.length,\n          boundedTurns,\n          previousActive,\n        );\n\n        cards.forEach((card, index) => {\n          const base = (index / cards.length) * Math.PI * 2 - Math.PI;\n          const theta = base + rotation;\n          const x = Math.cos(theta) * radius;\n          const y = Math.sin(theta) * radius;\n\n          const distanceToFocus = shortestAngleDistance(theta, topAnchor);\n          const focusIntensity = clamp(distanceToFocus / focusArc, 0, 1);\n\n          const darkIntensity = clamp(\n            focusIntensity * boundedDarknessStrength,\n            0,\n            1,\n          );\n          const saturationIntensity = clamp(\n            focusIntensity * boundedSaturationStrength,\n            0,\n            1,\n          );\n\n          const currentBlur = darkIntensity * boundedBlur;\n          const peakBrightness = clamp(100 + boundedBrightnessBoost, 100, 220);\n          const currentBrightness =\n            boundedDim + (1 - darkIntensity) * (peakBrightness - boundedDim);\n          const currentSaturation =\n            boundedMinSaturation +\n            (1 - saturationIntensity) * (100 - boundedMinSaturation);\n          const currentScale = 1 - darkIntensity * boundedScaleEffect;\n          const drift = clamp(x / radius, -1, 1);\n          const tilt = drift * 8;\n          const depth = clamp((1 - focusIntensity) * 100, 0, 100);\n\n          gsap.set(card, {\n            x,\n            y,\n            xPercent: -50,\n            yPercent: -50,\n            z: depth,\n            rotate: tilt,\n            scale: currentScale,\n            filter: `blur(${currentBlur}px) brightness(${currentBrightness}%) saturate(${currentSaturation}%)`,\n            zIndex: Math.round(depth),\n          });\n        });\n\n        if (focusedIndex !== previousActive) {\n          previousActive = focusedIndex;\n          setActiveIndex(focusedIndex);\n        }\n      };\n\n      applyState(0);\n\n      const trigger = ScrollTrigger.create({\n        trigger: section,\n        scroller: scrollContainerRef?.current ?? undefined,\n        start: \"top top\",\n        end: \"bottom bottom\",\n        scrub: true,\n        onUpdate: (self) => {\n          applyState(self.progress);\n        },\n      });\n\n      wheelScrollTriggerRef.current = trigger;\n    }, sectionRef);\n\n    ScrollTrigger.refresh();\n\n    return () => context.revert();\n  }, [\n    scrollContainerRef,\n    images,\n    radius,\n    boundedTurns,\n    boundedBlur,\n    boundedDim,\n    boundedBrightnessBoost,\n    boundedDarknessStrength,\n    boundedMinSaturation,\n    boundedSaturationStrength,\n    boundedFocusSpread,\n    boundedScaleEffect,\n    boundedScrollSensitivity,\n  ]);\n\n  useEffect(() => {\n    return () => {\n      titleClickTweenRef.current?.kill();\n      wheelScrollTriggerRef.current = null;\n    };\n  }, []);\n\n  useLayoutEffect(() => {\n    const viewport = titleViewportRef.current;\n    const track = titleTrackRef.current;\n    const startSpacer = titleStartSpacerRef.current;\n    const endSpacer = titleEndSpacerRef.current;\n    if (!viewport || !track || titleTrackLabels.length === 0) return;\n\n    if (!titleTrackXToRef.current) {\n      titleTrackXToRef.current = gsap.quickTo(track, \"x\", {\n        duration: 0.62,\n        ease: \"power4.out\",\n        overwrite: true,\n      });\n    }\n\n    const firstTitle = track.querySelector<HTMLElement>(\n      `[data-title-index=\"0\"]`,\n    );\n    const lastTitle = track.querySelector<HTMLElement>(\n      `[data-title-index=\"${titleTrackLabels.length - 1}\"]`,\n    );\n\n    const activeTitle = track.querySelector<HTMLElement>(\n      `[data-title-index=\"${activeTitleTrackIndex}\"]`,\n    );\n    if (!activeTitle || !firstTitle || !lastTitle) return;\n\n    const viewportWidthPx = viewport.clientWidth;\n\n    // Add edge spacers so the first and last pills can be centered.\n    const startPad = Math.max(\n      0,\n      viewportWidthPx / 2 - firstTitle.offsetWidth / 2,\n    );\n    const endPad = Math.max(0, viewportWidthPx / 2 - lastTitle.offsetWidth / 2);\n\n    if (startSpacer) {\n      startSpacer.style.width = `${Math.round(startPad)}px`;\n    }\n\n    if (endSpacer) {\n      endSpacer.style.width = `${Math.round(endPad)}px`;\n    }\n\n    const activeCenter = activeTitle.offsetLeft + activeTitle.offsetWidth / 2;\n\n    let targetX = Math.round(viewportWidthPx / 2 - activeCenter);\n\n    if (track.scrollWidth <= viewportWidthPx) {\n      targetX = Math.round((viewportWidthPx - track.scrollWidth) / 2);\n    } else {\n      const minX = viewportWidthPx - track.scrollWidth;\n      targetX = Math.round(clamp(targetX, minX, 0));\n    }\n\n    titleTrackXToRef.current(targetX);\n  }, [activeTitleTrackIndex, titleTrackLabels, viewportWidth]);\n\n  const activeImage = useMemo(() => {\n    if (images.length === 0) return null;\n    return images[activeIndex] ?? images[0];\n  }, [images, activeIndex]);\n\n  if (images.length === 0) {\n    return null;\n  }\n\n  return (\n    <section\n      ref={sectionRef}\n      className={cn(\"relative w-full\", className)}\n      style={{ height: `${boundedScrollLength}vh` }}\n    >\n      <div\n        ref={viewportRef}\n        className=\"sticky top-0 h-screen w-full overflow-hidden\"\n      >\n        <div\n          ref={wheelRef}\n          className=\"absolute left-1/2 -translate-x-1/2\"\n          style={{\n            width: boundedWheelSize,\n            height: boundedWheelSize,\n            bottom: `-${boundedWheelSize * boundedCropRatio}px`,\n          }}\n        >\n          <div\n            className=\"relative h-full w-full\"\n            style={{ perspective: \"1200px\" }}\n          >\n            {images.map((img, i) => (\n              <figure\n                key={i}\n                className=\"oiw-item absolute left-1/2 top-1/2 m-0 overflow-hidden rounded-xl\"\n                style={{ width: boundedItemWidth, height: boundedItemHeight }}\n              >\n                <div\n                  className=\"absolute inset-0 h-full w-full bg-cover bg-center\"\n                  style={{ backgroundImage: `url(${img.src})` }}\n                  role=\"img\"\n                  aria-label={img.alt ?? img.label ?? `Image ${i + 1}`}\n                />\n              </figure>\n            ))}\n          </div>\n        </div>\n\n        {showCaption && activeImage && (\n          <div\n            className=\"pointer-events-none absolute inset-x-0 z-30 flex justify-center\"\n            style={{ bottom: `${boundedCaptionOffset}vh` }}\n          >\n            <div className=\"px-6 text-center\">\n              <MotionSubtitle\n                text={activeImage.subtitle ?? activeImage.alt ?? \"Visual Story\"}\n                direction={boundedSubtitleDirection}\n                speed={boundedSubtitleSpeed}\n                stagger={boundedSubtitleStagger}\n                className=\"mb-2 text-[clamp(0.8rem,1vw,0.95rem)] tracking-[0.04em] text-foreground/45\"\n              />\n\n              <div\n                ref={titleViewportRef}\n                className=\"pointer-events-auto mx-auto w-[min(92vw,760px)] overflow-hidden py-1\"\n                style={{\n                  WebkitMaskImage:\n                    \"linear-gradient(to right, transparent 0%, black 14%, black 86%, transparent 100%)\",\n                  maskImage:\n                    \"linear-gradient(to right, transparent 0%, black 14%, black 86%, transparent 100%)\",\n                }}\n              >\n                <div ref={titleTrackRef} className=\"flex w-max items-center\">\n                  <span\n                    ref={titleStartSpacerRef}\n                    aria-hidden\n                    className=\"block h-px shrink-0\"\n                  />\n\n                  {titleTrackLabels.map((title, i) => (\n                    <button\n                      type=\"button\"\n                      key={`${title}-${i}`}\n                      data-title-index={i}\n                      onClick={() => handleTitleClick(i)}\n                      aria-current={\n                        i === activeTitleTrackIndex ? \"true\" : undefined\n                      }\n                      style={{\n                        opacity:\n                          Math.abs(i - activeTitleTrackIndex) === 0\n                            ? 1\n                            : Math.abs(i - activeTitleTrackIndex) === 1\n                              ? 0.58\n                              : Math.abs(i - activeTitleTrackIndex) === 2\n                                ? 0.32\n                                : 0.16,\n                        transform:\n                          Math.abs(i - activeTitleTrackIndex) === 0\n                            ? \"scale(1)\"\n                            : \"scale(0.96)\",\n                      }}\n                      className={cn(\n                        \"oiw-title-item mr-3 inline-flex shrink-0 cursor-pointer appearance-none items-center justify-center whitespace-nowrap rounded-full border border-foreground/35 px-7 py-2 text-center leading-none text-[clamp(1.05rem,2.25vw,2rem)] font-medium tracking-tight transition-[opacity,transform,color,border-color] duration-300\",\n                        i === activeTitleTrackIndex\n                          ? \"border-foreground/40 text-foreground\"\n                          : \"border-foreground/28 text-foreground/45\",\n                      )}\n                    >\n                      {title}\n                    </button>\n                  ))}\n\n                  <span\n                    ref={titleEndSpacerRef}\n                    aria-hidden\n                    className=\"block h-px shrink-0\"\n                  />\n                </div>\n              </div>\n            </div>\n          </div>\n        )}\n      </div>\n    </section>\n  );\n}\n\nexport default OrbitalImageWheel;\n",
      "type": "registry:ui",
      "target": "components/unlumen-ui/orbital-image-wheel.tsx"
    }
  ],
  "type": "registry:ui"
}