{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "slug": "orbit-gallery",
  "name": "Orbit Gallery",
  "title": "Orbit Gallery",
  "type": "registry:ui",
  "description": "A smooth, interactive photo gallery arranged in concentric rings with synchronized scrolling, dragging, hover and image expansion.",
  "summary": "Counter-rotating image rings surround a clear center. Scroll or drag to explore, select a photo to expand it, and customize the ring geometry and motion in the studio. A composited DOM renderer keeps images and controls accessible without requiring WebGL.",
  "category": "galleries-and-media",
  "tags": [
    "gallery",
    "orbit",
    "images",
    "rings",
    "scroll",
    "drag",
    "gsap",
    "motion"
  ],
  "dependencies": [
    "gsap",
    "@gsap/react",
    "@radix-ui/react-dialog",
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "version": "1.0.0",
  "createdDate": "2026-10-02",
  "updatedDate": "2026-10-02",
  "interactive": true,
  "supportsColor": false,
  "highlights": [
    "Concentric rings with alternating synchronized rotation",
    "Inertial wheel and pointer dragging with smooth hover scaling",
    "Selected images expand and return to their original ring position",
    "Responsive geometry contained within the studio canvas",
    "Live geometry controls and three coordinated motion presets",
    "Keyboard navigation, focus restoration and reduced-motion support"
  ],
  "anatomy": [
    "<OrbitGallery> contained and labelled gallery region",
    "Concentric rings of semantic image buttons",
    "Shared angular motion state and scoped GSAP animation lifecycle",
    "Portaled Radix dialog for image expansion",
    "Accessible pause and resume control"
  ],
  "physics": {
    "engine": "GSAP",
    "description": "A single angular state drives every ring. GSAP quickTo eases scroll input and drag targets, and scoped tweens coordinate hover scaling and image expansion. The rings pause during image selection and retain their phase across customization.",
    "parameters": [
      {
        "label": "Rotation",
        "value": "3°/s"
      },
      {
        "label": "Scroll settling",
        "value": "0.8s"
      },
      {
        "label": "Image expansion",
        "value": "0.65s"
      },
      {
        "label": "Hover scale",
        "value": "1.12×"
      }
    ]
  },
  "accessibility": {
    "role": "region / list / dialog",
    "aria": "Image buttons use meaningful labels. Repeated ring images are excluded from the tab sequence. The dialog traps focus and restores the selected trigger after closing.",
    "keyboard": [
      {
        "key": "Tab / Shift+Tab",
        "description": "Move between the gallery region, the first-ring images and the pause control."
      },
      {
        "key": "ArrowLeft / ArrowRight",
        "description": "Rotate the rings."
      },
      {
        "key": "Enter / Space",
        "description": "Open the focused image or activate the pause control."
      },
      {
        "key": "Space",
        "description": "Pause or resume rotation when the gallery region itself is focused."
      },
      {
        "key": "Escape",
        "description": "Close the expanded image."
      }
    ],
    "reducedMotion": "Disables automatic rotation and image tilt, and makes manual navigation, hover and dialog changes immediate."
  },
  "guidelines": {
    "recommended": [
      "Portfolio and photography galleries",
      "Visual discovery and immersive image collections"
    ],
    "bestPractices": [
      "Supply high-resolution images with descriptive alt text.",
      "Place the gallery inside a container with an explicit height.",
      "Keep a centered heading in a sibling layer with pointer-events-none.",
      "Adjust ring spacing and image size together to preserve breathing room."
    ]
  },
  "props": [
    {
      "name": "items",
      "type": "OrbitGalleryItem[]",
      "description": "Images with src and meaningful alt text.",
      "required": true
    },
    {
      "name": "ringCount",
      "type": "number",
      "description": "Number of concentric rings, clamped to 1–5.",
      "defaultValue": "3"
    },
    {
      "name": "radius",
      "type": "number",
      "description": "Inner radius at the 1600px design width, clamped to 220–550.",
      "defaultValue": "350"
    },
    {
      "name": "ringGap",
      "type": "number",
      "description": "Spacing between rings at the design width, clamped to 100–260.",
      "defaultValue": "185"
    },
    {
      "name": "imageSize",
      "type": "number",
      "description": "Thumbnail width at the design width, clamped to 40–160. Scales with the container.",
      "defaultValue": "96"
    },
    {
      "name": "imageAspectRatio",
      "type": "number",
      "description": "Width-to-height ratio, clamped to 0.5–1.5.",
      "defaultValue": "0.84"
    },
    {
      "name": "borderRadius",
      "type": "number",
      "description": "Image corner radius in pixels, clamped to 0–40.",
      "defaultValue": "12"
    },
    {
      "name": "speed",
      "type": "number",
      "description": "Automatic rotation in degrees per second, clamped to 0–18.",
      "defaultValue": "3"
    },
    {
      "name": "autoRotate",
      "type": "boolean",
      "description": "Enable automatic ring rotation.",
      "defaultValue": "true"
    },
    {
      "name": "reverse",
      "type": "boolean",
      "description": "Reverse automatic rotation.",
      "defaultValue": "false"
    },
    {
      "name": "pauseOnHover",
      "type": "boolean",
      "description": "Ease automatic motion to a stop while the pointer is in the gallery.",
      "defaultValue": "true"
    },
    {
      "name": "tilt",
      "type": "number",
      "description": "Maximum image tilt in degrees, clamped to 0–65.",
      "defaultValue": "38"
    },
    {
      "name": "inertia",
      "type": "number",
      "description": "Scroll and drag settling duration in seconds, clamped to 0.15–2.",
      "defaultValue": "0.8"
    },
    {
      "name": "hoverScale",
      "type": "number",
      "description": "Thumbnail scale on hover or keyboard focus, clamped to 1–1.4.",
      "defaultValue": "1.12"
    },
    {
      "name": "scrollSensitivity",
      "type": "number",
      "description": "Degrees of scroll rotation per pixel, clamped to 0.01–0.3.",
      "defaultValue": "0.06"
    },
    {
      "name": "dragSensitivity",
      "type": "number",
      "description": "Degrees of drag rotation per pixel, clamped to 0.05–0.6.",
      "defaultValue": "0.22"
    },
    {
      "name": "motionDuration",
      "type": "number",
      "description": "Opening and closing duration in seconds, clamped to 0.2–1.5.",
      "defaultValue": "0.65"
    },
    {
      "name": "showControls",
      "type": "boolean",
      "description": "Show the accessible pause and resume control.",
      "defaultValue": "true"
    },
    {
      "name": "className",
      "type": "string",
      "description": "Additional classes for the contained gallery stage."
    },
    {
      "name": "ariaLabel",
      "type": "string",
      "description": "Accessible label for the gallery region.",
      "defaultValue": "\"Orbit image gallery\""
    },
    {
      "name": "onReady",
      "type": "() => void",
      "description": "Called once when the gallery is measured and positioned. Does not require WebGL."
    },
    {
      "name": "onSelect",
      "type": "(item: OrbitGalleryItem | null) => void",
      "description": "Called with the selected image on opening, and null when closing begins."
    }
  ],
  "usage": "import { OrbitGallery } from \"@/components/ui/orbit-gallery\";\n\nconst items = [\n  { src: \"/images/orbit-1.jpg\", alt: \"Mountain lake\" },\n  { src: \"/images/orbit-2.jpg\", alt: \"City at night\" },\n  { src: \"/images/orbit-3.jpg\", alt: \"Desert road\" },\n];\n\nexport function OrbitGalleryDemo() {\n  return (\n    <div className=\"relative h-[640px] w-full\">\n      <OrbitGallery items={items} />\n    </div>\n  );\n}",
  "files": [
    {
      "name": "orbit-gallery.tsx",
      "path": "registry/base/orbit-gallery/orbit-gallery.tsx",
      "code": "\"use client\";\n\nimport * as React from \"react\";\nimport * as Dialog from \"@radix-ui/react-dialog\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { Pause, Play, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\ngsap.registerPlugin(useGSAP);\n\nexport interface OrbitGalleryItem {\n  src: string;\n  alt: string;\n}\n\nexport interface OrbitGalleryProps {\n  items: OrbitGalleryItem[];\n  ringCount?: number;\n  radius?: number;\n  ringGap?: number;\n  imageSize?: number;\n  imageAspectRatio?: number;\n  borderRadius?: number;\n  speed?: number;\n  reverse?: boolean;\n  autoRotate?: boolean;\n  pauseOnHover?: boolean;\n  tilt?: number;\n  inertia?: number;\n  hoverScale?: number;\n  scrollSensitivity?: number;\n  dragSensitivity?: number;\n  motionDuration?: number;\n  showControls?: boolean;\n  className?: string;\n  ariaLabel?: string;\n  onReady?: () => void;\n  onSelect?: (item: OrbitGalleryItem | null) => void;\n}\n\nconst bounded = (value: number, fallback: number, min: number, max: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\ntype Selection = {\n  item: OrbitGalleryItem;\n  source: HTMLButtonElement;\n  aspectRatio: number;\n};\ntype Slot = { ring: number; angle: number; itemIndex: number };\n\nfunction OrbitImage({\n  selection,\n  open,\n  duration,\n  borderRadius,\n  overlayRef,\n  onExit,\n}: {\n  selection: Selection;\n  open: boolean;\n  duration: number;\n  borderRadius: number;\n  overlayRef: React.RefObject<HTMLDivElement | null>;\n  onExit: () => void;\n}) {\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const closeRef = React.useRef<HTMLButtonElement>(null);\n  const timelineRef = React.useRef<gsap.core.Timeline | null>(null);\n  const initializedRef = React.useRef(false);\n  const transitionRef = React.useRef<(() => void) | null>(null);\n  const { item, source, aspectRatio } = selection;\n\n  useGSAP(\n    () => {\n      const resize = () => transitionRef.current?.();\n      const media = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n      window.addEventListener(\"resize\", resize);\n      media.addEventListener(\"change\", resize);\n      return () => {\n        window.removeEventListener(\"resize\", resize);\n        media.removeEventListener(\"change\", resize);\n        timelineRef.current?.kill();\n        initializedRef.current = false;\n        source.style.visibility = \"\";\n      };\n    },\n    { scope: panelRef },\n  );\n\n  useGSAP(\n    (_context, contextSafe) => {\n      const animate = contextSafe!(() => {\n        const panel = panelRef.current;\n        const overlay = overlayRef.current;\n        if (!panel || !overlay) return;\n        const reduced = window.matchMedia(\n          \"(prefers-reduced-motion: reduce)\",\n        ).matches;\n        const rect = source.getBoundingClientRect();\n        const rotation = Number(gsap.getProperty(source, \"rotation\")) || 0;\n        const thumbnail = source.querySelector<HTMLSpanElement>(\n          \"[data-orbit-thumbnail]\",\n        );\n        const scale = thumbnail\n          ? Number(gsap.getProperty(thumbnail, \"scaleX\")) || 1\n          : 1;\n        const origin = {\n          x: rect.left + rect.width / 2 - (source.offsetWidth * scale) / 2,\n          y: rect.top + rect.height / 2 - (source.offsetHeight * scale) / 2,\n          width: source.offsetWidth * scale,\n          height: source.offsetHeight * scale,\n          rotation,\n        };\n        const width = Math.min(\n          600,\n          window.innerWidth - 40,\n          (window.innerHeight - 100) * aspectRatio,\n        );\n        const target = {\n          x: (window.innerWidth - width) / 2,\n          y: (window.innerHeight - width / aspectRatio) / 2,\n          width,\n          height: width / aspectRatio,\n          rotation: 0,\n        };\n        timelineRef.current?.kill();\n        if (!initializedRef.current) {\n          gsap.set(panel, origin);\n          gsap.set([overlay, closeRef.current], { opacity: 0 });\n          source.style.visibility = \"hidden\";\n          initializedRef.current = true;\n        }\n        gsap.set(panel, { opacity: 1 });\n        const time = reduced ? 0 : duration;\n        const timeline = gsap.timeline({\n          onComplete: open\n            ? undefined\n            : contextSafe!(() => {\n                source.style.visibility = \"\";\n                onExit();\n              }),\n        });\n        timeline\n          .to(\n            panel,\n            {\n              ...(open ? target : origin),\n              duration: time,\n              ease: \"power3.inOut\",\n            },\n            0,\n          )\n          .to(\n            overlay,\n            {\n              opacity: open ? 1 : 0,\n              duration: time * 0.75,\n              ease: \"power2.out\",\n            },\n            0,\n          )\n          .to(\n            closeRef.current,\n            { opacity: open ? 1 : 0, duration: time * 0.3 },\n            open ? time * 0.65 : 0,\n          );\n        timelineRef.current = timeline;\n      });\n      transitionRef.current = animate;\n      animate();\n    },\n    {\n      scope: panelRef,\n      dependencies: [open, duration, aspectRatio, onExit, overlayRef, source],\n      revertOnUpdate: false,\n    },\n  );\n\n  return (\n    <Dialog.Content\n      ref={panelRef}\n      aria-describedby={undefined}\n      onOpenAutoFocus={(event) => {\n        event.preventDefault();\n        closeRef.current?.focus({ preventScroll: true });\n      }}\n      onCloseAutoFocus={(event) => {\n        event.preventDefault();\n        source.focus({ preventScroll: true });\n      }}\n      onInteractOutside={(event) => {\n        if (!open) event.preventDefault();\n      }}\n      className=\"fixed left-0 top-0 z-50 overflow-hidden bg-[#141419] shadow-2xl outline-none\"\n      style={{\n        borderRadius,\n        opacity: 0,\n        willChange: \"transform, width, height\",\n      }}\n    >\n      <Dialog.Title className=\"sr-only\">{item.alt}</Dialog.Title>\n      <img\n        src={item.src}\n        alt={item.alt}\n        draggable={false}\n        className=\"h-full w-full object-cover\"\n      />\n      <Dialog.Close asChild>\n        <button\n          ref={closeRef}\n          type=\"button\"\n          aria-label=\"Close image\"\n          className=\"absolute right-3 top-3 flex size-10 cursor-pointer items-center justify-center rounded-full border border-white/20 bg-black/60 text-white backdrop-blur-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white\"\n        >\n          <X size={18} aria-hidden=\"true\" />\n        </button>\n      </Dialog.Close>\n    </Dialog.Content>\n  );\n}\n\nexport function OrbitGallery({\n  items,\n  ringCount = 3,\n  radius = 350,\n  ringGap = 185,\n  imageSize = 96,\n  imageAspectRatio = 0.84,\n  borderRadius = 12,\n  speed = 3,\n  reverse = false,\n  autoRotate = true,\n  pauseOnHover = true,\n  tilt = 38,\n  inertia = 0.8,\n  hoverScale = 1.12,\n  scrollSensitivity = 0.06,\n  dragSensitivity = 0.22,\n  motionDuration = 0.65,\n  showControls = true,\n  className,\n  ariaLabel = \"Orbit image gallery\",\n  onReady,\n  onSelect,\n}: OrbitGalleryProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const overlayRef = React.useRef<HTMLDivElement>(null);\n  const phaseRef = React.useRef({ rotation: 0, offset: 0 });\n  const readyRef = React.useRef(onReady);\n  const notifiedRef = React.useRef(false);\n  const interactiveRef = React.useRef<{\n    hover: (button: HTMLButtonElement, active: boolean) => void;\n    focus: (index: number) => void;\n  } | null>(null);\n  const [paused, setPaused] = React.useState(false);\n  const [selection, setSelection] = React.useState<Selection | null>(null);\n  const [open, setOpen] = React.useState(false);\n  const selectedRef = React.useRef(false);\n  const pausedRef = React.useRef(false);\n  const count = Math.round(bounded(ringCount, 3, 1, 5));\n  const size = bounded(imageSize, 96, 40, 160);\n  const ratio = bounded(imageAspectRatio, 0.84, 0.5, 1.5);\n  const corners = bounded(borderRadius, 12, 0, 40);\n  const easing = bounded(inertia, 0.8, 0.15, 2);\n  const duration = bounded(motionDuration, 0.65, 0.2, 1.5);\n  const slots = React.useMemo(() => {\n    if (!items.length) return [];\n    return Array.from({ length: count }, (_, ring) => {\n      const length = Math.min(\n        40,\n        Math.max(12, Math.round(items.length * (1 + ring * 0.3))),\n      );\n      return Array.from(\n        { length },\n        (_, index): Slot => ({\n          ring,\n          angle: (index * 360) / length + ring * 9,\n          itemIndex: (index + ring * 7) % items.length,\n        }),\n      );\n    }).flat();\n  }, [count, items.length]);\n\n  React.useEffect(() => {\n    readyRef.current = onReady;\n  }, [onReady]);\n  React.useEffect(() => {\n    selectedRef.current = Boolean(selection);\n  }, [selection]);\n  React.useEffect(() => {\n    pausedRef.current = paused;\n  }, [paused]);\n\n  useGSAP(\n    (_context, contextSafe) => {\n      const root = rootRef.current;\n      if (!root) return;\n      const buttons = Array.from(\n        root.querySelectorAll<HTMLButtonElement>(\"[data-orbit-item]\"),\n      );\n      const setters = buttons.map((button) => gsap.quickSetter(button, \"css\"));\n      const phase = phaseRef.current;\n      const media = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n      let reduced = media.matches;\n      let width = 0;\n      let height = 0;\n      let targetOffset = phase.offset;\n      let visible = true;\n      let hovered = false;\n      let lastRotation = NaN;\n      let lastOffset = NaN;\n      let ignoreClickUntil = 0;\n      let dragging: {\n        id: number;\n        x: number;\n        y: number;\n        offset: number;\n        active: boolean;\n      } | null = null;\n      const move = gsap.quickTo(phase, \"offset\", {\n        duration: easing,\n        ease: \"power3.out\",\n      });\n      const motion = { velocity: 0 };\n      const changeVelocity = gsap.quickTo(motion, \"velocity\", {\n        duration: 0.6,\n        ease: \"power2.out\",\n      });\n      let targetVelocity = -1;\n      const render = (force = false) => {\n        if (\n          !width ||\n          !height ||\n          (!force &&\n            phase.rotation === lastRotation &&\n            phase.offset === lastOffset)\n        )\n          return;\n        const scale = Math.max(0.32, width / 1600);\n        const imageWidth = Math.max(30, size * scale);\n        buttons.forEach((_button, index) => {\n          const slot = slots[index];\n          const direction = slot.ring % 2 ? -1 : 1;\n          const angle =\n            slot.angle +\n            direction *\n              (phase.rotation + phase.offset) *\n              (1 + slot.ring * 0.12);\n          const radians = (angle * Math.PI) / 180;\n          const distance =\n            (bounded(radius, 350, 220, 550) +\n              slot.ring * bounded(ringGap, 185, 100, 260)) *\n            scale;\n          setters[index]({\n            x: width / 2 + Math.cos(radians) * distance - imageWidth / 2,\n            y:\n              height / 2 +\n              Math.sin(radians) * distance -\n              imageWidth / ratio / 2,\n            rotation: reduced\n              ? 0\n              : Math.sin(radians * 2) * bounded(tilt, 38, 0, 65),\n          });\n        });\n        lastRotation = phase.rotation;\n        lastOffset = phase.offset;\n      };\n      const measure = () => {\n        width = root.clientWidth;\n        height = root.clientHeight;\n        const imageWidth = Math.max(30, size * Math.max(0.32, width / 1600));\n        gsap.set(buttons, { width: imageWidth, height: imageWidth / ratio });\n        gsap.set(\n          buttons.map((button) =>\n            button.querySelector(\"[data-orbit-thumbnail]\"),\n          ),\n          { borderRadius: corners * Math.min(1, Math.max(0.32, width / 1600)) },\n        );\n        render(true);\n        if (width && height && !notifiedRef.current) {\n          notifiedRef.current = true;\n          readyRef.current?.();\n        }\n      };\n      const advance = (delta: number) => {\n        targetOffset += delta;\n        if (reduced) {\n          move.tween.pause();\n          phase.offset = targetOffset;\n          render();\n        } else move(targetOffset);\n      };\n      const wheel = (event: WheelEvent) => {\n        if (\n          event.ctrlKey ||\n          event.metaKey ||\n          selectedRef.current ||\n          !slots.length\n        )\n          return;\n        event.preventDefault();\n        const delta =\n          (event.deltaY || event.deltaX) *\n          (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? height : 1);\n        advance(\n          Math.max(-160, Math.min(160, delta)) *\n            bounded(scrollSensitivity, 0.06, 0.01, 0.3),\n        );\n      };\n      const pointerDown = (event: PointerEvent) => {\n        if (\n          event.button !== 0 ||\n          selectedRef.current ||\n          (event.target as HTMLElement).closest(\"[data-orbit-control]\")\n        )\n          return;\n        dragging = {\n          id: event.pointerId,\n          x: event.clientX,\n          y: event.clientY,\n          offset: targetOffset,\n          active: false,\n        };\n      };\n      const pointerMove = (event: PointerEvent) => {\n        if (!dragging || event.pointerId !== dragging.id) return;\n        const delta =\n          event.clientX - dragging.x + (event.clientY - dragging.y) * 0.5;\n        if (\n          !dragging.active &&\n          Math.hypot(event.clientX - dragging.x, event.clientY - dragging.y) < 6\n        )\n          return;\n        if (!dragging.active) {\n          dragging.active = true;\n          root.setPointerCapture(event.pointerId);\n        }\n        targetOffset =\n          dragging.offset - delta * bounded(dragSensitivity, 0.22, 0.05, 0.6);\n        if (reduced) {\n          phase.offset = targetOffset;\n          render();\n        } else move(targetOffset);\n      };\n      const pointerUp = (event: PointerEvent) => {\n        if (!dragging || event.pointerId !== dragging.id) return;\n        if (dragging.active) {\n          ignoreClickUntil = performance.now() + 350;\n          if (root.hasPointerCapture(event.pointerId))\n            root.releasePointerCapture(event.pointerId);\n        }\n        dragging = null;\n      };\n      const captureClick = (event: MouseEvent) => {\n        if (performance.now() < ignoreClickUntil) {\n          event.preventDefault();\n          event.stopPropagation();\n        }\n      };\n      const keyboard = (event: KeyboardEvent) => {\n        if (\n          selectedRef.current ||\n          (event.target as HTMLElement).closest(\"[data-orbit-control]\")\n        )\n          return;\n        if (event.key === \"ArrowLeft\" || event.key === \"ArrowRight\") {\n          event.preventDefault();\n          advance(event.key === \"ArrowRight\" ? 18 : -18);\n        }\n        if (event.key === \" \" && event.target === root) {\n          event.preventDefault();\n          setPaused((value) => !value);\n        }\n      };\n      const enter = () => {\n        hovered = true;\n      };\n      const leave = () => {\n        hovered = false;\n      };\n      const changeMotion = () => {\n        reduced = media.matches;\n        move.tween.pause();\n        targetOffset = phase.offset;\n        render(true);\n      };\n      const tick = (_time: number, delta: number) => {\n        if (!visible || document.hidden || selectedRef.current) {\n          move.tween.pause();\n          targetOffset = phase.offset;\n          return;\n        }\n        const velocity =\n          !reduced &&\n          autoRotate &&\n          !pausedRef.current &&\n          !(pauseOnHover && hovered) &&\n          !dragging\n            ? 1\n            : 0;\n        if (velocity !== targetVelocity) {\n          targetVelocity = velocity;\n          if (reduced) {\n            changeVelocity.tween.pause();\n            motion.velocity = 0;\n          } else changeVelocity(velocity);\n        }\n        if (motion.velocity) {\n          phase.rotation +=\n            (Math.min(delta, 40) / 1000) *\n            bounded(speed, 3, 0, 18) *\n            motion.velocity *\n            (reverse ? -1 : 1);\n        }\n        render();\n      };\n      interactiveRef.current = {\n        hover: contextSafe!((button: HTMLButtonElement, active: boolean) => {\n          const thumbnail = button.querySelector(\"[data-orbit-thumbnail]\");\n          gsap.to(thumbnail, {\n            scale: active ? bounded(hoverScale, 1.12, 1, 1.4) : 1,\n            duration: reduced ? 0 : 0.4,\n            ease: \"power3.out\",\n            overwrite: true,\n          });\n          button.style.zIndex = active ? \"2\" : \"1\";\n        }),\n        focus: (index: number) => {\n          const button = buttons[index];\n          if (!button) return;\n          const rect = button.getBoundingClientRect();\n          const frame = root.getBoundingClientRect();\n          if (rect.top < frame.top + 10 || rect.bottom > frame.bottom - 10) {\n            const slot = slots[index];\n            const rate = (slot.ring % 2 ? -1 : 1) * (1 + slot.ring * 0.12);\n            const angle = slot.angle + rate * (phase.rotation + phase.offset);\n            advance(-(((((angle + 180) % 360) + 360) % 360) - 180) / rate);\n          }\n        },\n      };\n      const resize = new ResizeObserver(measure);\n      const intersection = new IntersectionObserver(\n        ([entry]) => {\n          visible = entry.isIntersecting;\n        },\n        { threshold: 0 },\n      );\n      resize.observe(root);\n      intersection.observe(root);\n      root.addEventListener(\"wheel\", wheel, { passive: false });\n      root.addEventListener(\"pointerdown\", pointerDown);\n      root.addEventListener(\"pointermove\", pointerMove);\n      root.addEventListener(\"pointerup\", pointerUp);\n      root.addEventListener(\"pointercancel\", pointerUp);\n      root.addEventListener(\"lostpointercapture\", pointerUp);\n      root.addEventListener(\"click\", captureClick, true);\n      root.addEventListener(\"keydown\", keyboard);\n      root.addEventListener(\"pointerenter\", enter);\n      root.addEventListener(\"pointerleave\", leave);\n      media.addEventListener(\"change\", changeMotion);\n      measure();\n      gsap.ticker.add(tick);\n      return () => {\n        gsap.ticker.remove(tick);\n        move.tween.kill();\n        changeVelocity.tween.kill();\n        resize.disconnect();\n        intersection.disconnect();\n        root.removeEventListener(\"wheel\", wheel);\n        root.removeEventListener(\"pointerdown\", pointerDown);\n        root.removeEventListener(\"pointermove\", pointerMove);\n        root.removeEventListener(\"pointerup\", pointerUp);\n        root.removeEventListener(\"pointercancel\", pointerUp);\n        root.removeEventListener(\"lostpointercapture\", pointerUp);\n        root.removeEventListener(\"click\", captureClick, true);\n        root.removeEventListener(\"keydown\", keyboard);\n        root.removeEventListener(\"pointerenter\", enter);\n        root.removeEventListener(\"pointerleave\", leave);\n        media.removeEventListener(\"change\", changeMotion);\n        interactiveRef.current = null;\n      };\n    },\n    {\n      scope: rootRef,\n      dependencies: [\n        slots,\n        size,\n        ratio,\n        corners,\n        radius,\n        ringGap,\n        speed,\n        reverse,\n        autoRotate,\n        pauseOnHover,\n        tilt,\n        easing,\n        hoverScale,\n        scrollSensitivity,\n        dragSensitivity,\n      ],\n      revertOnUpdate: true,\n    },\n  );\n\n  const exit = React.useCallback(() => {\n    selectedRef.current = false;\n    setSelection(null);\n  }, []);\n  const close = (value: boolean) => {\n    if (!value) {\n      setOpen(false);\n      onSelect?.(null);\n    }\n  };\n\n  return (\n    <Dialog.Root open={Boolean(selection)} onOpenChange={close}>\n      <div\n        ref={rootRef}\n        role=\"region\"\n        aria-label={ariaLabel}\n        tabIndex={0}\n        className={cn(\n          \"relative isolate h-full min-h-64 w-full overflow-hidden bg-[#0c0c10] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/40\",\n          className,\n        )}\n        style={{ touchAction: \"pan-y\" }}\n      >\n        <div\n          className=\"absolute inset-0\"\n          role=\"list\"\n          aria-label=\"Gallery images\"\n        >\n          {slots.map((slot, index) => {\n            const item = items[slot.itemIndex];\n            return (\n              <div key={`${slot.ring}-${index}`} role=\"listitem\">\n                <button\n                  type=\"button\"\n                  data-orbit-item\n                  aria-label={`View ${item.alt}, ring ${slot.ring + 1}`}\n                  aria-haspopup=\"dialog\"\n                  tabIndex={slot.ring === 0 && index < items.length ? 0 : -1}\n                  onPointerEnter={(event) =>\n                    interactiveRef.current?.hover(event.currentTarget, true)\n                  }\n                  onPointerLeave={(event) => {\n                    if (!event.currentTarget.matches(\":focus-visible\"))\n                      interactiveRef.current?.hover(event.currentTarget, false);\n                  }}\n                  onFocus={(event) => {\n                    if (event.currentTarget.matches(\":focus-visible\")) {\n                      interactiveRef.current?.hover(event.currentTarget, true);\n                      interactiveRef.current?.focus(index);\n                    }\n                  }}\n                  onBlur={(event) =>\n                    interactiveRef.current?.hover(event.currentTarget, false)\n                  }\n                  onClick={(event) => {\n                    if (selectedRef.current) return;\n                    selectedRef.current = true;\n                    const image = event.currentTarget.querySelector(\"img\");\n                    setSelection({\n                      item,\n                      source: event.currentTarget,\n                      aspectRatio:\n                        image?.naturalWidth && image.naturalHeight\n                          ? image.naturalWidth / image.naturalHeight\n                          : ratio,\n                    });\n                    setOpen(true);\n                    onSelect?.(item);\n                  }}\n                  className=\"absolute left-0 top-0 z-1 cursor-pointer border-0 bg-transparent p-0 outline-none will-change-transform focus-visible:z-10\"\n                  style={{ width: 0, height: 0 }}\n                >\n                  <span\n                    data-orbit-thumbnail\n                    className=\"block h-full w-full overflow-hidden bg-[#26262d] shadow-sm ring-white/80 in-focus-visible:ring-2\"\n                    style={{ borderRadius: corners }}\n                  >\n                    <img\n                      src={item.src}\n                      alt=\"\"\n                      draggable={false}\n                      loading=\"eager\"\n                      decoding=\"async\"\n                      className=\"h-full w-full select-none object-cover\"\n                    />\n                  </span>\n                </button>\n              </div>\n            );\n          })}\n        </div>\n        {showControls && autoRotate && (\n          <button\n            type=\"button\"\n            data-orbit-control\n            onClick={() => setPaused((value) => !value)}\n            aria-label={\n              paused ? \"Resume gallery motion\" : \"Pause gallery motion\"\n            }\n            aria-pressed={paused}\n            className=\"absolute bottom-4 left-4 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-white/15 bg-[#0c0c10]/80 text-white/60 backdrop-blur-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white\"\n          >\n            {paused ? (\n              <Play size={14} aria-hidden=\"true\" />\n            ) : (\n              <Pause size={14} aria-hidden=\"true\" />\n            )}\n          </button>\n        )}\n      </div>\n      {selection && (\n        <Dialog.Portal>\n          <Dialog.Overlay\n            ref={overlayRef}\n            className=\"fixed inset-0 z-40 bg-black/80 backdrop-blur-sm\"\n          />\n          <OrbitImage\n            selection={selection}\n            open={open}\n            duration={duration}\n            borderRadius={corners}\n            overlayRef={overlayRef}\n            onExit={exit}\n          />\n        </Dialog.Portal>\n      )}\n    </Dialog.Root>\n  );\n}\n\nexport default OrbitGallery;\n",
      "type": "registry:ui",
      "target": "@ui/orbit-gallery.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as Dialog from \"@radix-ui/react-dialog\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { Pause, Play, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\ngsap.registerPlugin(useGSAP);\n\nexport interface OrbitGalleryItem {\n  src: string;\n  alt: string;\n}\n\nexport interface OrbitGalleryProps {\n  items: OrbitGalleryItem[];\n  ringCount?: number;\n  radius?: number;\n  ringGap?: number;\n  imageSize?: number;\n  imageAspectRatio?: number;\n  borderRadius?: number;\n  speed?: number;\n  reverse?: boolean;\n  autoRotate?: boolean;\n  pauseOnHover?: boolean;\n  tilt?: number;\n  inertia?: number;\n  hoverScale?: number;\n  scrollSensitivity?: number;\n  dragSensitivity?: number;\n  motionDuration?: number;\n  showControls?: boolean;\n  className?: string;\n  ariaLabel?: string;\n  onReady?: () => void;\n  onSelect?: (item: OrbitGalleryItem | null) => void;\n}\n\nconst bounded = (value: number, fallback: number, min: number, max: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\ntype Selection = {\n  item: OrbitGalleryItem;\n  source: HTMLButtonElement;\n  aspectRatio: number;\n};\ntype Slot = { ring: number; angle: number; itemIndex: number };\n\nfunction OrbitImage({\n  selection,\n  open,\n  duration,\n  borderRadius,\n  overlayRef,\n  onExit,\n}: {\n  selection: Selection;\n  open: boolean;\n  duration: number;\n  borderRadius: number;\n  overlayRef: React.RefObject<HTMLDivElement | null>;\n  onExit: () => void;\n}) {\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const closeRef = React.useRef<HTMLButtonElement>(null);\n  const timelineRef = React.useRef<gsap.core.Timeline | null>(null);\n  const initializedRef = React.useRef(false);\n  const transitionRef = React.useRef<(() => void) | null>(null);\n  const { item, source, aspectRatio } = selection;\n\n  useGSAP(\n    () => {\n      const resize = () => transitionRef.current?.();\n      const media = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n      window.addEventListener(\"resize\", resize);\n      media.addEventListener(\"change\", resize);\n      return () => {\n        window.removeEventListener(\"resize\", resize);\n        media.removeEventListener(\"change\", resize);\n        timelineRef.current?.kill();\n        initializedRef.current = false;\n        source.style.visibility = \"\";\n      };\n    },\n    { scope: panelRef },\n  );\n\n  useGSAP(\n    (_context, contextSafe) => {\n      const animate = contextSafe!(() => {\n        const panel = panelRef.current;\n        const overlay = overlayRef.current;\n        if (!panel || !overlay) return;\n        const reduced = window.matchMedia(\n          \"(prefers-reduced-motion: reduce)\",\n        ).matches;\n        const rect = source.getBoundingClientRect();\n        const rotation = Number(gsap.getProperty(source, \"rotation\")) || 0;\n        const thumbnail = source.querySelector<HTMLSpanElement>(\n          \"[data-orbit-thumbnail]\",\n        );\n        const scale = thumbnail\n          ? Number(gsap.getProperty(thumbnail, \"scaleX\")) || 1\n          : 1;\n        const origin = {\n          x: rect.left + rect.width / 2 - (source.offsetWidth * scale) / 2,\n          y: rect.top + rect.height / 2 - (source.offsetHeight * scale) / 2,\n          width: source.offsetWidth * scale,\n          height: source.offsetHeight * scale,\n          rotation,\n        };\n        const width = Math.min(\n          600,\n          window.innerWidth - 40,\n          (window.innerHeight - 100) * aspectRatio,\n        );\n        const target = {\n          x: (window.innerWidth - width) / 2,\n          y: (window.innerHeight - width / aspectRatio) / 2,\n          width,\n          height: width / aspectRatio,\n          rotation: 0,\n        };\n        timelineRef.current?.kill();\n        if (!initializedRef.current) {\n          gsap.set(panel, origin);\n          gsap.set([overlay, closeRef.current], { opacity: 0 });\n          source.style.visibility = \"hidden\";\n          initializedRef.current = true;\n        }\n        gsap.set(panel, { opacity: 1 });\n        const time = reduced ? 0 : duration;\n        const timeline = gsap.timeline({\n          onComplete: open\n            ? undefined\n            : contextSafe!(() => {\n                source.style.visibility = \"\";\n                onExit();\n              }),\n        });\n        timeline\n          .to(\n            panel,\n            {\n              ...(open ? target : origin),\n              duration: time,\n              ease: \"power3.inOut\",\n            },\n            0,\n          )\n          .to(\n            overlay,\n            {\n              opacity: open ? 1 : 0,\n              duration: time * 0.75,\n              ease: \"power2.out\",\n            },\n            0,\n          )\n          .to(\n            closeRef.current,\n            { opacity: open ? 1 : 0, duration: time * 0.3 },\n            open ? time * 0.65 : 0,\n          );\n        timelineRef.current = timeline;\n      });\n      transitionRef.current = animate;\n      animate();\n    },\n    {\n      scope: panelRef,\n      dependencies: [open, duration, aspectRatio, onExit, overlayRef, source],\n      revertOnUpdate: false,\n    },\n  );\n\n  return (\n    <Dialog.Content\n      ref={panelRef}\n      aria-describedby={undefined}\n      onOpenAutoFocus={(event) => {\n        event.preventDefault();\n        closeRef.current?.focus({ preventScroll: true });\n      }}\n      onCloseAutoFocus={(event) => {\n        event.preventDefault();\n        source.focus({ preventScroll: true });\n      }}\n      onInteractOutside={(event) => {\n        if (!open) event.preventDefault();\n      }}\n      className=\"fixed left-0 top-0 z-50 overflow-hidden bg-[#141419] shadow-2xl outline-none\"\n      style={{\n        borderRadius,\n        opacity: 0,\n        willChange: \"transform, width, height\",\n      }}\n    >\n      <Dialog.Title className=\"sr-only\">{item.alt}</Dialog.Title>\n      <img\n        src={item.src}\n        alt={item.alt}\n        draggable={false}\n        className=\"h-full w-full object-cover\"\n      />\n      <Dialog.Close asChild>\n        <button\n          ref={closeRef}\n          type=\"button\"\n          aria-label=\"Close image\"\n          className=\"absolute right-3 top-3 flex size-10 cursor-pointer items-center justify-center rounded-full border border-white/20 bg-black/60 text-white backdrop-blur-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white\"\n        >\n          <X size={18} aria-hidden=\"true\" />\n        </button>\n      </Dialog.Close>\n    </Dialog.Content>\n  );\n}\n\nexport function OrbitGallery({\n  items,\n  ringCount = 3,\n  radius = 350,\n  ringGap = 185,\n  imageSize = 96,\n  imageAspectRatio = 0.84,\n  borderRadius = 12,\n  speed = 3,\n  reverse = false,\n  autoRotate = true,\n  pauseOnHover = true,\n  tilt = 38,\n  inertia = 0.8,\n  hoverScale = 1.12,\n  scrollSensitivity = 0.06,\n  dragSensitivity = 0.22,\n  motionDuration = 0.65,\n  showControls = true,\n  className,\n  ariaLabel = \"Orbit image gallery\",\n  onReady,\n  onSelect,\n}: OrbitGalleryProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const overlayRef = React.useRef<HTMLDivElement>(null);\n  const phaseRef = React.useRef({ rotation: 0, offset: 0 });\n  const readyRef = React.useRef(onReady);\n  const notifiedRef = React.useRef(false);\n  const interactiveRef = React.useRef<{\n    hover: (button: HTMLButtonElement, active: boolean) => void;\n    focus: (index: number) => void;\n  } | null>(null);\n  const [paused, setPaused] = React.useState(false);\n  const [selection, setSelection] = React.useState<Selection | null>(null);\n  const [open, setOpen] = React.useState(false);\n  const selectedRef = React.useRef(false);\n  const pausedRef = React.useRef(false);\n  const count = Math.round(bounded(ringCount, 3, 1, 5));\n  const size = bounded(imageSize, 96, 40, 160);\n  const ratio = bounded(imageAspectRatio, 0.84, 0.5, 1.5);\n  const corners = bounded(borderRadius, 12, 0, 40);\n  const easing = bounded(inertia, 0.8, 0.15, 2);\n  const duration = bounded(motionDuration, 0.65, 0.2, 1.5);\n  const slots = React.useMemo(() => {\n    if (!items.length) return [];\n    return Array.from({ length: count }, (_, ring) => {\n      const length = Math.min(\n        40,\n        Math.max(12, Math.round(items.length * (1 + ring * 0.3))),\n      );\n      return Array.from(\n        { length },\n        (_, index): Slot => ({\n          ring,\n          angle: (index * 360) / length + ring * 9,\n          itemIndex: (index + ring * 7) % items.length,\n        }),\n      );\n    }).flat();\n  }, [count, items.length]);\n\n  React.useEffect(() => {\n    readyRef.current = onReady;\n  }, [onReady]);\n  React.useEffect(() => {\n    selectedRef.current = Boolean(selection);\n  }, [selection]);\n  React.useEffect(() => {\n    pausedRef.current = paused;\n  }, [paused]);\n\n  useGSAP(\n    (_context, contextSafe) => {\n      const root = rootRef.current;\n      if (!root) return;\n      const buttons = Array.from(\n        root.querySelectorAll<HTMLButtonElement>(\"[data-orbit-item]\"),\n      );\n      const setters = buttons.map((button) => gsap.quickSetter(button, \"css\"));\n      const phase = phaseRef.current;\n      const media = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n      let reduced = media.matches;\n      let width = 0;\n      let height = 0;\n      let targetOffset = phase.offset;\n      let visible = true;\n      let hovered = false;\n      let lastRotation = NaN;\n      let lastOffset = NaN;\n      let ignoreClickUntil = 0;\n      let dragging: {\n        id: number;\n        x: number;\n        y: number;\n        offset: number;\n        active: boolean;\n      } | null = null;\n      const move = gsap.quickTo(phase, \"offset\", {\n        duration: easing,\n        ease: \"power3.out\",\n      });\n      const motion = { velocity: 0 };\n      const changeVelocity = gsap.quickTo(motion, \"velocity\", {\n        duration: 0.6,\n        ease: \"power2.out\",\n      });\n      let targetVelocity = -1;\n      const render = (force = false) => {\n        if (\n          !width ||\n          !height ||\n          (!force &&\n            phase.rotation === lastRotation &&\n            phase.offset === lastOffset)\n        )\n          return;\n        const scale = Math.max(0.32, width / 1600);\n        const imageWidth = Math.max(30, size * scale);\n        buttons.forEach((_button, index) => {\n          const slot = slots[index];\n          const direction = slot.ring % 2 ? -1 : 1;\n          const angle =\n            slot.angle +\n            direction *\n              (phase.rotation + phase.offset) *\n              (1 + slot.ring * 0.12);\n          const radians = (angle * Math.PI) / 180;\n          const distance =\n            (bounded(radius, 350, 220, 550) +\n              slot.ring * bounded(ringGap, 185, 100, 260)) *\n            scale;\n          setters[index]({\n            x: width / 2 + Math.cos(radians) * distance - imageWidth / 2,\n            y:\n              height / 2 +\n              Math.sin(radians) * distance -\n              imageWidth / ratio / 2,\n            rotation: reduced\n              ? 0\n              : Math.sin(radians * 2) * bounded(tilt, 38, 0, 65),\n          });\n        });\n        lastRotation = phase.rotation;\n        lastOffset = phase.offset;\n      };\n      const measure = () => {\n        width = root.clientWidth;\n        height = root.clientHeight;\n        const imageWidth = Math.max(30, size * Math.max(0.32, width / 1600));\n        gsap.set(buttons, { width: imageWidth, height: imageWidth / ratio });\n        gsap.set(\n          buttons.map((button) =>\n            button.querySelector(\"[data-orbit-thumbnail]\"),\n          ),\n          { borderRadius: corners * Math.min(1, Math.max(0.32, width / 1600)) },\n        );\n        render(true);\n        if (width && height && !notifiedRef.current) {\n          notifiedRef.current = true;\n          readyRef.current?.();\n        }\n      };\n      const advance = (delta: number) => {\n        targetOffset += delta;\n        if (reduced) {\n          move.tween.pause();\n          phase.offset = targetOffset;\n          render();\n        } else move(targetOffset);\n      };\n      const wheel = (event: WheelEvent) => {\n        if (\n          event.ctrlKey ||\n          event.metaKey ||\n          selectedRef.current ||\n          !slots.length\n        )\n          return;\n        event.preventDefault();\n        const delta =\n          (event.deltaY || event.deltaX) *\n          (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? height : 1);\n        advance(\n          Math.max(-160, Math.min(160, delta)) *\n            bounded(scrollSensitivity, 0.06, 0.01, 0.3),\n        );\n      };\n      const pointerDown = (event: PointerEvent) => {\n        if (\n          event.button !== 0 ||\n          selectedRef.current ||\n          (event.target as HTMLElement).closest(\"[data-orbit-control]\")\n        )\n          return;\n        dragging = {\n          id: event.pointerId,\n          x: event.clientX,\n          y: event.clientY,\n          offset: targetOffset,\n          active: false,\n        };\n      };\n      const pointerMove = (event: PointerEvent) => {\n        if (!dragging || event.pointerId !== dragging.id) return;\n        const delta =\n          event.clientX - dragging.x + (event.clientY - dragging.y) * 0.5;\n        if (\n          !dragging.active &&\n          Math.hypot(event.clientX - dragging.x, event.clientY - dragging.y) < 6\n        )\n          return;\n        if (!dragging.active) {\n          dragging.active = true;\n          root.setPointerCapture(event.pointerId);\n        }\n        targetOffset =\n          dragging.offset - delta * bounded(dragSensitivity, 0.22, 0.05, 0.6);\n        if (reduced) {\n          phase.offset = targetOffset;\n          render();\n        } else move(targetOffset);\n      };\n      const pointerUp = (event: PointerEvent) => {\n        if (!dragging || event.pointerId !== dragging.id) return;\n        if (dragging.active) {\n          ignoreClickUntil = performance.now() + 350;\n          if (root.hasPointerCapture(event.pointerId))\n            root.releasePointerCapture(event.pointerId);\n        }\n        dragging = null;\n      };\n      const captureClick = (event: MouseEvent) => {\n        if (performance.now() < ignoreClickUntil) {\n          event.preventDefault();\n          event.stopPropagation();\n        }\n      };\n      const keyboard = (event: KeyboardEvent) => {\n        if (\n          selectedRef.current ||\n          (event.target as HTMLElement).closest(\"[data-orbit-control]\")\n        )\n          return;\n        if (event.key === \"ArrowLeft\" || event.key === \"ArrowRight\") {\n          event.preventDefault();\n          advance(event.key === \"ArrowRight\" ? 18 : -18);\n        }\n        if (event.key === \" \" && event.target === root) {\n          event.preventDefault();\n          setPaused((value) => !value);\n        }\n      };\n      const enter = () => {\n        hovered = true;\n      };\n      const leave = () => {\n        hovered = false;\n      };\n      const changeMotion = () => {\n        reduced = media.matches;\n        move.tween.pause();\n        targetOffset = phase.offset;\n        render(true);\n      };\n      const tick = (_time: number, delta: number) => {\n        if (!visible || document.hidden || selectedRef.current) {\n          move.tween.pause();\n          targetOffset = phase.offset;\n          return;\n        }\n        const velocity =\n          !reduced &&\n          autoRotate &&\n          !pausedRef.current &&\n          !(pauseOnHover && hovered) &&\n          !dragging\n            ? 1\n            : 0;\n        if (velocity !== targetVelocity) {\n          targetVelocity = velocity;\n          if (reduced) {\n            changeVelocity.tween.pause();\n            motion.velocity = 0;\n          } else changeVelocity(velocity);\n        }\n        if (motion.velocity) {\n          phase.rotation +=\n            (Math.min(delta, 40) / 1000) *\n            bounded(speed, 3, 0, 18) *\n            motion.velocity *\n            (reverse ? -1 : 1);\n        }\n        render();\n      };\n      interactiveRef.current = {\n        hover: contextSafe!((button: HTMLButtonElement, active: boolean) => {\n          const thumbnail = button.querySelector(\"[data-orbit-thumbnail]\");\n          gsap.to(thumbnail, {\n            scale: active ? bounded(hoverScale, 1.12, 1, 1.4) : 1,\n            duration: reduced ? 0 : 0.4,\n            ease: \"power3.out\",\n            overwrite: true,\n          });\n          button.style.zIndex = active ? \"2\" : \"1\";\n        }),\n        focus: (index: number) => {\n          const button = buttons[index];\n          if (!button) return;\n          const rect = button.getBoundingClientRect();\n          const frame = root.getBoundingClientRect();\n          if (rect.top < frame.top + 10 || rect.bottom > frame.bottom - 10) {\n            const slot = slots[index];\n            const rate = (slot.ring % 2 ? -1 : 1) * (1 + slot.ring * 0.12);\n            const angle = slot.angle + rate * (phase.rotation + phase.offset);\n            advance(-(((((angle + 180) % 360) + 360) % 360) - 180) / rate);\n          }\n        },\n      };\n      const resize = new ResizeObserver(measure);\n      const intersection = new IntersectionObserver(\n        ([entry]) => {\n          visible = entry.isIntersecting;\n        },\n        { threshold: 0 },\n      );\n      resize.observe(root);\n      intersection.observe(root);\n      root.addEventListener(\"wheel\", wheel, { passive: false });\n      root.addEventListener(\"pointerdown\", pointerDown);\n      root.addEventListener(\"pointermove\", pointerMove);\n      root.addEventListener(\"pointerup\", pointerUp);\n      root.addEventListener(\"pointercancel\", pointerUp);\n      root.addEventListener(\"lostpointercapture\", pointerUp);\n      root.addEventListener(\"click\", captureClick, true);\n      root.addEventListener(\"keydown\", keyboard);\n      root.addEventListener(\"pointerenter\", enter);\n      root.addEventListener(\"pointerleave\", leave);\n      media.addEventListener(\"change\", changeMotion);\n      measure();\n      gsap.ticker.add(tick);\n      return () => {\n        gsap.ticker.remove(tick);\n        move.tween.kill();\n        changeVelocity.tween.kill();\n        resize.disconnect();\n        intersection.disconnect();\n        root.removeEventListener(\"wheel\", wheel);\n        root.removeEventListener(\"pointerdown\", pointerDown);\n        root.removeEventListener(\"pointermove\", pointerMove);\n        root.removeEventListener(\"pointerup\", pointerUp);\n        root.removeEventListener(\"pointercancel\", pointerUp);\n        root.removeEventListener(\"lostpointercapture\", pointerUp);\n        root.removeEventListener(\"click\", captureClick, true);\n        root.removeEventListener(\"keydown\", keyboard);\n        root.removeEventListener(\"pointerenter\", enter);\n        root.removeEventListener(\"pointerleave\", leave);\n        media.removeEventListener(\"change\", changeMotion);\n        interactiveRef.current = null;\n      };\n    },\n    {\n      scope: rootRef,\n      dependencies: [\n        slots,\n        size,\n        ratio,\n        corners,\n        radius,\n        ringGap,\n        speed,\n        reverse,\n        autoRotate,\n        pauseOnHover,\n        tilt,\n        easing,\n        hoverScale,\n        scrollSensitivity,\n        dragSensitivity,\n      ],\n      revertOnUpdate: true,\n    },\n  );\n\n  const exit = React.useCallback(() => {\n    selectedRef.current = false;\n    setSelection(null);\n  }, []);\n  const close = (value: boolean) => {\n    if (!value) {\n      setOpen(false);\n      onSelect?.(null);\n    }\n  };\n\n  return (\n    <Dialog.Root open={Boolean(selection)} onOpenChange={close}>\n      <div\n        ref={rootRef}\n        role=\"region\"\n        aria-label={ariaLabel}\n        tabIndex={0}\n        className={cn(\n          \"relative isolate h-full min-h-64 w-full overflow-hidden bg-[#0c0c10] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/40\",\n          className,\n        )}\n        style={{ touchAction: \"pan-y\" }}\n      >\n        <div\n          className=\"absolute inset-0\"\n          role=\"list\"\n          aria-label=\"Gallery images\"\n        >\n          {slots.map((slot, index) => {\n            const item = items[slot.itemIndex];\n            return (\n              <div key={`${slot.ring}-${index}`} role=\"listitem\">\n                <button\n                  type=\"button\"\n                  data-orbit-item\n                  aria-label={`View ${item.alt}, ring ${slot.ring + 1}`}\n                  aria-haspopup=\"dialog\"\n                  tabIndex={slot.ring === 0 && index < items.length ? 0 : -1}\n                  onPointerEnter={(event) =>\n                    interactiveRef.current?.hover(event.currentTarget, true)\n                  }\n                  onPointerLeave={(event) => {\n                    if (!event.currentTarget.matches(\":focus-visible\"))\n                      interactiveRef.current?.hover(event.currentTarget, false);\n                  }}\n                  onFocus={(event) => {\n                    if (event.currentTarget.matches(\":focus-visible\")) {\n                      interactiveRef.current?.hover(event.currentTarget, true);\n                      interactiveRef.current?.focus(index);\n                    }\n                  }}\n                  onBlur={(event) =>\n                    interactiveRef.current?.hover(event.currentTarget, false)\n                  }\n                  onClick={(event) => {\n                    if (selectedRef.current) return;\n                    selectedRef.current = true;\n                    const image = event.currentTarget.querySelector(\"img\");\n                    setSelection({\n                      item,\n                      source: event.currentTarget,\n                      aspectRatio:\n                        image?.naturalWidth && image.naturalHeight\n                          ? image.naturalWidth / image.naturalHeight\n                          : ratio,\n                    });\n                    setOpen(true);\n                    onSelect?.(item);\n                  }}\n                  className=\"absolute left-0 top-0 z-1 cursor-pointer border-0 bg-transparent p-0 outline-none will-change-transform focus-visible:z-10\"\n                  style={{ width: 0, height: 0 }}\n                >\n                  <span\n                    data-orbit-thumbnail\n                    className=\"block h-full w-full overflow-hidden bg-[#26262d] shadow-sm ring-white/80 in-focus-visible:ring-2\"\n                    style={{ borderRadius: corners }}\n                  >\n                    <img\n                      src={item.src}\n                      alt=\"\"\n                      draggable={false}\n                      loading=\"eager\"\n                      decoding=\"async\"\n                      className=\"h-full w-full select-none object-cover\"\n                    />\n                  </span>\n                </button>\n              </div>\n            );\n          })}\n        </div>\n        {showControls && autoRotate && (\n          <button\n            type=\"button\"\n            data-orbit-control\n            onClick={() => setPaused((value) => !value)}\n            aria-label={\n              paused ? \"Resume gallery motion\" : \"Pause gallery motion\"\n            }\n            aria-pressed={paused}\n            className=\"absolute bottom-4 left-4 z-10 flex size-9 cursor-pointer items-center justify-center rounded-full border border-white/15 bg-[#0c0c10]/80 text-white/60 backdrop-blur-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white\"\n          >\n            {paused ? (\n              <Play size={14} aria-hidden=\"true\" />\n            ) : (\n              <Pause size={14} aria-hidden=\"true\" />\n            )}\n          </button>\n        )}\n      </div>\n      {selection && (\n        <Dialog.Portal>\n          <Dialog.Overlay\n            ref={overlayRef}\n            className=\"fixed inset-0 z-40 bg-black/80 backdrop-blur-sm\"\n          />\n          <OrbitImage\n            selection={selection}\n            open={open}\n            duration={duration}\n            borderRadius={corners}\n            overlayRef={overlayRef}\n            onExit={exit}\n          />\n        </Dialog.Portal>\n      )}\n    </Dialog.Root>\n  );\n}\n\nexport default OrbitGallery;\n"
    }
  ]
}
