{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "circular-gallery",
  "type": "registry:ui",
  "dependencies": [
    "gsap"
  ],
  "files": [
    {
      "path": "components/ui/circular-gallery.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useEffect, useRef } from \"react\";\nimport gsap from \"gsap\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Circular Gallery\n *\n * A relaxing 3D ring of images: many small cards are laid out around a giant\n * tilted circle, the whole ring drifts with a gentle auto-rotation you can\n * grab and spin, and the cursor parallaxes the tilt. Hovering a card lifts it\n * and mirrors it in a large centre preview.\n *\n * Ported from the vanilla \"CodeGrid 3D Circular Image Gallery\" (GSAP) into a\n * single, self-contained, prop-driven React component. The original's full-page\n * ScrollTrigger is replaced with drag + auto-rotation so it works inside any\n * container. GSAP is the only runtime dependency.\n */\n\nexport interface CircularGalleryProps {\n  /** Image URLs, cycled around the ring. When omitted, neutral placeholder cards are shown. */\n  images?: string[];\n  /** Number of cards in the ring. Defaults to 150. */\n  count?: number;\n  /** Base tilt of the ring in degrees (rotateX). Defaults to 55. */\n  tilt?: number;\n  /** Ring radius in px (card distance from centre). Defaults to 400. */\n  radius?: number;\n  /** Card width in px. Defaults to 45. */\n  itemWidth?: number;\n  /** Card height in px. Defaults to 60. */\n  itemHeight?: number;\n  /** Slowly spin the ring on its own. Defaults to true. */\n  autoRotate?: boolean;\n  /** Auto-rotation speed in degrees per second. Defaults to 4. */\n  autoRotateSpeed?: number;\n  /** Show the large centre preview that follows the hovered card. Defaults to true. */\n  showPreview?: boolean;\n  /** Parallax the ring's tilt toward the cursor. Defaults to true. */\n  parallax?: boolean;\n  /** Extra classes for the root element. */\n  className?: string;\n}\n\nexport function CircularGallery({\n  images,\n  count = 150,\n  tilt = 55,\n  radius = 400,\n  itemWidth = 45,\n  itemHeight = 60,\n  autoRotate = true,\n  autoRotateSpeed = 3,\n  showPreview = true,\n  parallax = true,\n  className,\n}: CircularGalleryProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const galleryRef = useRef<HTMLDivElement>(null);\n  const previewRef = useRef<HTMLImageElement>(null);\n  const previewWrapRef = useRef<HTMLDivElement>(null);\n\n  const srcOf = (i: number) =>\n    images && images.length > 0 ? images[i % images.length] : undefined;\n  const defaultPreview = srcOf(0);\n\n  // Live-tunable knobs read inside the ticker/handlers without rebuilding.\n  const optsRef = useRef({ autoRotate, autoRotateSpeed, parallax, tilt });\n  useEffect(() => {\n    optsRef.current = { autoRotate, autoRotateSpeed, parallax, tilt };\n  }, [autoRotate, autoRotateSpeed, parallax, tilt]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const gallery = galleryRef.current;\n    if (!root || !gallery) return;\n\n    const items = gsap.utils.toArray<HTMLElement>(gallery.querySelectorAll(\"[data-ring-item]\"));\n    if (items.length === 0) return;\n\n    const angleIncrement = 360 / items.length;\n    const baseAngles = items.map((_, i) => i * angleIncrement - 90);\n\n    // Seat each card on the ring, facing outward.\n    items.forEach((item, i) => {\n      gsap.set(item, {\n        rotationY: 90,\n        rotationZ: baseAngles[i],\n        transformOrigin: `50% ${radius}px`,\n      });\n    });\n    gsap.set(gallery, { rotationY: 0 });\n    // Centre preview stays hidden until a card is hovered.\n    if (previewWrapRef.current) gsap.set(previewWrapRef.current, { opacity: 0 });\n\n    const setZ = items.map((item) => gsap.quickSetter(item, \"rotationZ\", \"deg\"));\n\n    // ── Tasteful entrance: the ring settles into its tilt, fades up, and the\n    // cards bloom in softly at random. ──────────────────────────────────────\n    gsap.fromTo(\n      gallery,\n      { rotationX: optsRef.current.tilt + 16, opacity: 0 },\n      { rotationX: optsRef.current.tilt, opacity: 1, duration: 1.4, ease: \"power3.out\" },\n    );\n    gsap.from(items, {\n      opacity: 0,\n      duration: 0.7,\n      ease: \"power1.out\",\n      stagger: { amount: 1, from: \"random\" },\n    });\n\n    // ── Rotation: eased current chasing a target, nudged by auto-spin + drag ──\n    let current = 0;\n    let target = 0;\n\n    const tick = () => {\n      const { autoRotate: auto, autoRotateSpeed: speed } = optsRef.current;\n      if (auto && !dragging) target += (speed / 60) * gsap.ticker.deltaRatio();\n      current += (target - current) * 0.05;\n      for (let i = 0; i < setZ.length; i++) setZ[i](baseAngles[i] + current);\n    };\n    gsap.ticker.add(tick);\n\n    // ── Drag to spin ─────────────────────────────────────────────────────\n    let dragging = false;\n    let lastX = 0;\n\n    const onPointerDown = (e: PointerEvent) => {\n      dragging = true;\n      lastX = e.clientX;\n      root.setPointerCapture?.(e.pointerId);\n      root.style.cursor = \"grabbing\";\n    };\n    const onPointerMove = (e: PointerEvent) => {\n      // Parallax tilt toward the cursor.\n      if (optsRef.current.parallax) {\n        const rect = root.getBoundingClientRect();\n        const px = (e.clientX - rect.left) / rect.width - 0.5;\n        const py = (e.clientY - rect.top) / rect.height - 0.5;\n        gsap.to(gallery, {\n          rotationX: optsRef.current.tilt + py * 3,\n          rotationY: px * 3,\n          duration: 1.4,\n          ease: \"power2.out\",\n          overwrite: \"auto\",\n        });\n      }\n      if (dragging) {\n        target += (e.clientX - lastX) * 0.3;\n        lastX = e.clientX;\n      }\n    };\n    const endDrag = (e: PointerEvent) => {\n      if (!dragging) return;\n      dragging = false;\n      root.releasePointerCapture?.(e.pointerId);\n      root.style.cursor = \"grab\";\n    };\n\n    root.addEventListener(\"pointerdown\", onPointerDown);\n    root.addEventListener(\"pointermove\", onPointerMove);\n    root.addEventListener(\"pointerup\", endDrag);\n    root.addEventListener(\"pointerleave\", endDrag);\n\n    return () => {\n      gsap.ticker.remove(tick);\n      root.removeEventListener(\"pointerdown\", onPointerDown);\n      root.removeEventListener(\"pointermove\", onPointerMove);\n      root.removeEventListener(\"pointerup\", endDrag);\n      root.removeEventListener(\"pointerleave\", endDrag);\n      gsap.killTweensOf(gallery);\n    };\n    // Rebuild when structural inputs change; live knobs flow through optsRef.\n  }, [count, radius, images]);\n\n  // Reveal the centre preview with the hovered card's image — swaps instantly\n  // so moving between cards feels immediate, and the frame fades in.\n  const showPreviewImage = (src?: string) => {\n    const img = previewRef.current;\n    const wrap = previewWrapRef.current;\n    if (!img || !wrap || !src) return;\n    if (!img.src.endsWith(src)) img.src = src;\n    gsap.to(wrap, { opacity: 1, duration: 0.15, ease: \"power2.out\", overwrite: true });\n  };\n\n  // Hide the preview when the cursor leaves the cards.\n  const hidePreviewImage = () => {\n    const wrap = previewWrapRef.current;\n    if (!wrap) return;\n    gsap.to(wrap, { opacity: 0, duration: 0.25, ease: \"power1.out\", overwrite: true });\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\n        \"relative h-full w-full touch-none select-none overflow-hidden [perspective:1500px]\",\n        \"bg-[radial-gradient(circle_at_50%_42%,#f7f7f8,#e6e6e8)] dark:bg-[radial-gradient(circle_at_50%_42%,#17171a,#050505)]\",\n        className,\n      )}\n      style={{ cursor: \"grab\" }}\n    >\n      {/* Centre preview — hidden until a card is hovered */}\n      {showPreview && defaultPreview ? (\n        <div\n          ref={previewWrapRef}\n          className=\"pointer-events-none absolute left-1/2 top-1/2 z-0 h-[220px] w-[330px] max-w-[72%] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-xl opacity-0 shadow-2xl ring-1 ring-black/10 dark:ring-white/10\"\n        >\n          {/* eslint-disable-next-line @next/next/no-img-element */}\n          <img ref={previewRef} src={defaultPreview} alt=\"\" className=\"h-full w-full object-cover\" />\n          {/* Soft scrim for depth */}\n          <div className=\"pointer-events-none absolute inset-0 bg-gradient-to-t from-black/25 via-transparent to-white/10\" />\n        </div>\n      ) : null}\n\n      {/* The ring */}\n      <div\n        ref={galleryRef}\n        className=\"absolute left-1/2 top-[20%] z-10 -translate-x-1/2 [transform-style:preserve-3d]\"\n      >\n        {Array.from({ length: count }).map((_, i) => {\n          const src = srcOf(i);\n          return (\n            <div\n              key={i}\n              data-ring-item\n              className=\"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-[3px] bg-neutral-300 shadow-md shadow-black/20 ring-1 ring-black/5 [transform-style:preserve-3d] dark:bg-neutral-700 dark:ring-white/10\"\n              style={{ width: itemWidth, height: itemHeight, margin: 10 }}\n            >\n              {src ? (\n                // eslint-disable-next-line @next/next/no-img-element\n                <img\n                  src={src}\n                  alt=\"\"\n                  onMouseEnter={() => showPreviewImage(src)}\n                  onMouseLeave={hidePreviewImage}\n                  className=\"h-full w-full object-cover transition-[transform,filter] duration-300 hover:scale-110 hover:brightness-110\"\n                />\n              ) : null}\n            </div>\n          );\n        })}\n      </div>\n\n      {/* Edge vignette so the ring fades softly at the periphery */}\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 z-20 bg-[radial-gradient(circle_at_50%_45%,transparent_52%,rgba(240,240,242,0.85))] dark:bg-[radial-gradient(circle_at_50%_45%,transparent_46%,rgba(5,5,5,0.9))]\"\n      />\n    </div>\n  );\n}\n\nexport default CircularGallery;\n",
      "type": "registry:ui",
      "target": "components/ui/circular-gallery.tsx"
    }
  ]
}
