{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"image-gallery","type":"registry:component","title":"Rotating Image Gallery","description":"A ring of photos you spin with a drag, a swipe or the arrow keys.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/image-gallery/image-gallery.tsx","type":"registry:component","target":"components/dimaac/image-gallery.tsx","content":"\"use client\";\n\nimport { useRef } from \"react\";\nimport { gsap } from \"gsap\";\nimport { Draggable } from \"gsap/Draggable\";\nimport { InertiaPlugin } from \"gsap/InertiaPlugin\";\nimport { MotionPathPlugin } from \"gsap/MotionPathPlugin\";\nimport { useGSAP } from \"@gsap/react\";\n\nif (typeof window !== \"undefined\") gsap.registerPlugin(Draggable, InertiaPlugin, MotionPathPlugin, useGSAP);\n\n// A ring of photos you can spin. Every photo sits on an invisible circle (MotionPath puts it there\n// and turns it to follow the curve), and the whole ring is one element, so spinning it is a single\n// rotation. Drag it and let go: InertiaPlugin throws it and it slows down by itself. It also turns\n// with the arrow keys, with a sideways trackpad swipe, and slowly on its own while nobody touches it.\n\nexport type GalleryImage = {\n  /** The image address. */\n  src: string;\n  /** What the picture shows, for screen readers. */\n  alt: string;\n};\n\nexport type ImageGalleryProps = {\n  /** The photos, placed around the ring in this order. Six to twelve look best. */\n  images: GalleryImage[];\n  /** How big the ring is, as a percentage of the smaller side of the box. */\n  ringSize?: number;\n  /** How big the photos are. 1 is as big as they can be without touching, above 1 they overlap like a fan. */\n  tileScale?: number;\n  /** The shape of each photo: square, portrait (4:5) or landscape (4:3). */\n  tileShape?: \"square\" | \"portrait\" | \"landscape\";\n  /** Corner radius of each photo, in pixels. */\n  radius?: number;\n  /** \"ring\" turns every photo with the ring, heads outward. \"upright\" keeps each photo level while the ring turns. */\n  tileMode?: \"ring\" | \"upright\";\n  /** Idle spin in degrees per second. 0 keeps the ring still until touched. Negative spins the other way. */\n  autoSpin?: number;\n  /** Stop the idle spin while a mouse pointer is over one of the photos. */\n  pauseOnHover?: boolean;\n  /** What the mouse wheel does: \"horizontal\" turns on a sideways swipe only (the page still scrolls), \"always\" turns on any wheel movement and traps the page scroll, \"off\" ignores the wheel. */\n  wheel?: \"horizontal\" | \"always\" | \"off\";\n  /** After a drag or a key press, come to rest with a photo exactly at the top. */\n  snap?: boolean;\n  /** On touch screens, let an up or down swipe scroll the page. Turn off if the gallery is the whole screen. */\n  touchScroll?: boolean;\n  /** Spin the ring in when it appears. */\n  intro?: boolean;\n  /** The size in pixels the image tags are given as width and height. Set it to the real size of your files. */\n  imageSize?: number;\n  /** The size of the drawing area of the circle the photos are placed on. Only its shape matters, so leave it at 400. */\n  circleSize?: number;\n  /** The accessible name of the gallery. */\n  label?: string;\n  /** Extra classes for the outer box. Give it a size (it fills it). */\n  className?: string;\n};\n\n// How much taller than wide each shape is\nconst SHAPES = { square: 1, portrait: 1.25, landscape: 0.75 } as const;\n\n// Width of one photo as a percentage of the ring's box. At a tileScale of 1 the photos just touch\n// their neighbours at their inner edge (the inner edge is the tight side: the circle is smaller\n// there). A bigger scale lets them overlap like a fan. It is shrunk if a photo would poke out\n// past the edge of the box.\nfunction tileWidth(count: number, ringSize: number, tileScale: number, ratio: number, upright: boolean) {\n  // The gap between two neighbours on a circle of radius 1 is 2 * sin(PI / count)\n  const around = count < 3 ? 0.5 : Math.min(Math.sin(Math.PI / count), 0.55);\n  const fit = upright ? ringSize * around * 0.78 : (ringSize * around * 0.96) / (1 + around * ratio);\n  let width = fit * tileScale;\n  const reach = upright ? Math.hypot(width, width * ratio) / 2 : (width * ratio) / 2;\n  const available = 49 - ringSize / 2;\n  // Never shrink below 55% of the wanted size, so the photo size control always does something\n  if (reach > available) width *= Math.max(available / reach, 0.55);\n  return Math.max(width, 3);\n}\n\nexport function ImageGallery({\n  images,\n  ringSize = 74,\n  tileScale = 1,\n  tileShape = \"portrait\",\n  radius = 10,\n  tileMode = \"ring\",\n  autoSpin = 5,\n  pauseOnHover = true,\n  wheel = \"horizontal\",\n  snap = false,\n  touchScroll = true,\n  intro = true,\n  imageSize = 400,\n  circleSize = 400,\n  label = \"Image gallery\",\n  className = \"\",\n}: ImageGalleryProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const ringRef = useRef<HTMLDivElement>(null);\n  const pathRef = useRef<SVGPathElement>(null);\n  const tileRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const count = images.length;\n  const upright = tileMode === \"upright\";\n  const ratio = SHAPES[tileShape];\n  const width = tileWidth(count, ringSize, tileScale, ratio, upright);\n\n  // Read on every frame or event, so changing them doesn't rebuild anything\n  const live = useRef({ autoSpin, pauseOnHover, wheel });\n  live.current = { autoSpin, pauseOnHover, wheel };\n  // Where the ring is turned to, kept so a control change (which rebuilds the ring) doesn't snap it back to 0\n  const savedRotation = useRef(0);\n\n  // 1. Put every photo on the circle. Runs again when the layout changes or the box is resized,\n  //    because MotionPath turns the circle into pixel positions.\n  useGSAP(\n    () => {\n      const ring = ringRef.current;\n      const path = pathRef.current;\n      const tiles = tileRefs.current.slice(0, count).filter((tile): tile is HTMLDivElement => tile !== null);\n      if (!ring || !path || tiles.length === 0) return;\n\n      const place = () => {\n        tiles.forEach((tile, index) => {\n          gsap.set(tile, { x: 0, y: 0 });\n          gsap.set(tile, {\n            motionPath: {\n              path,\n              align: path,\n              alignOrigin: [0.5, 0.5],\n              // The circle starts at 3 o'clock; +0.75 puts the first photo at 12 o'clock\n              start: 0,\n              end: (index / tiles.length + 0.75) % 1,\n              autoRotate: !upright,\n            },\n            visibility: \"visible\",\n          });\n        });\n      };\n      place();\n\n      let lastWidth = ring.offsetWidth;\n      const resize = new ResizeObserver(() => {\n        if (Math.abs(ring.offsetWidth - lastWidth) < 0.5) return;\n        lastWidth = ring.offsetWidth;\n        place();\n      });\n      resize.observe(ring);\n      return () => resize.disconnect();\n    },\n    { scope: rootRef, dependencies: [count, ringSize, tileScale, tileShape, tileMode, circleSize], revertOnUpdate: true },\n  );\n\n  // 2. Make the ring turn: drag with inertia, wheel, arrow keys and the idle spin\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      const ring = ringRef.current;\n      const tiles = tileRefs.current.slice(0, count).filter((tile): tile is HTMLDivElement => tile !== null);\n      if (!root || !ring || count === 0) return;\n\n      const mm = gsap.matchMedia();\n      mm.add({ reduce: \"(prefers-reduced-motion: reduce)\", normal: \"(prefers-reduced-motion: no-preference)\" }, (context) => {\n        const reduce = Boolean(context.conditions?.reduce);\n        const step = 360 / count;\n        const setRotation = gsap.quickSetter(ring, \"rotation\", \"deg\");\n        const setTileRotations = tiles.map((tile) => gsap.quickSetter(tile, \"rotation\", \"deg\"));\n        // Carry the turn over from before the rebuild\n        setRotation(savedRotation.current);\n\n        let pressed = false;\n        let hovering = false;\n        let onScreen = true;\n        let idleSeconds = 0;\n        // Where a wheel or key turn is heading, so quick repeats add up instead of restarting\n        let heading: number | null = null;\n        let lastRotation = Number.NaN;\n\n        const rotationNow = () => Number(gsap.getProperty(ring, \"rotation\"));\n\n        // In \"upright\" mode every photo turns the opposite way to the ring, so it stays level\n        const keepUpright = () => {\n          const rotation = rotationNow();\n          savedRotation.current = rotation;\n          if (!upright) return;\n          if (rotation === lastRotation) return;\n          lastRotation = rotation;\n          setTileRotations.forEach((set) => set(-rotation));\n        };\n        keepUpright();\n\n        const turnTo = (rotation: number) => {\n          heading = rotation;\n          if (reduce) {\n            gsap.killTweensOf(ring);\n            setRotation(rotation);\n            heading = null;\n            keepUpright();\n            return;\n          }\n          gsap.to(ring, {\n            rotation,\n            duration: 0.8,\n            ease: \"power3.out\",\n            overwrite: true,\n            onUpdate: keepUpright,\n            onComplete: () => {\n              heading = null;\n            },\n          });\n        };\n        const turnBy = (degrees: number) => turnTo((heading ?? rotationNow()) + degrees);\n\n        const drag = Draggable.create(ring, {\n          type: \"rotation\",\n          // Dragging works from anywhere in the box, not only on the ring's own square\n          trigger: root,\n          inertia: !reduce,\n          snap: snap ? (value: number) => Math.round(value / step) * step : undefined,\n          cursor: \"grab\",\n          activeCursor: \"grabbing\",\n          zIndexBoost: false,\n          onPress: () => {\n            pressed = true;\n            heading = null;\n            gsap.killTweensOf(ring);\n          },\n          onRelease: () => {\n            pressed = false;\n          },\n          onDrag: keepUpright,\n          onThrowUpdate: keepUpright,\n        })[0];\n        if (!drag) return;\n        // Draggable switches touch scrolling off for rotation. Hand the up and down swipe back to\n        // the page, so only a sideways swipe turns the ring.\n        if (touchScroll) root.style.touchAction = \"pan-y\";\n\n        const onWheel = (event: WheelEvent) => {\n          const mode = live.current.wheel;\n          if (mode === \"off\" || pressed) return;\n          const sideways = Math.abs(event.deltaX) > Math.abs(event.deltaY);\n          // In the default mode a plain vertical wheel is left alone, so the page can scroll past\n          if (mode === \"horizontal\" && !sideways) return;\n          event.preventDefault();\n          turnBy((sideways ? event.deltaX : event.deltaY) * 0.4);\n        };\n\n        const onKeyDown = (event: KeyboardEvent) => {\n          const forward = event.key === \"ArrowRight\" || event.key === \"ArrowDown\";\n          const back = event.key === \"ArrowLeft\" || event.key === \"ArrowUp\";\n          if (!forward && !back) return;\n          event.preventDefault();\n          // Always land on a whole step, so the arrow keys move one photo at a time\n          const from = heading ?? rotationNow();\n          turnTo(Math.round(from / step) * step + (forward ? step : -step));\n        };\n\n        // Hovering means a mouse pointer is over one of the photos (not the empty space around them)\n        const onOver = (event: PointerEvent) => {\n          hovering = event.pointerType !== \"touch\" && event.target instanceof Element && event.target.closest(\"[role=listitem]\") !== null;\n        };\n        const onLeave = () => {\n          hovering = false;\n        };\n\n        root.addEventListener(\"wheel\", onWheel, { passive: false });\n        root.addEventListener(\"keydown\", onKeyDown);\n        ring.addEventListener(\"pointerover\", onOver);\n        root.addEventListener(\"pointerleave\", onLeave);\n\n        const visibility = new IntersectionObserver(([entry]) => {\n          onScreen = entry?.isIntersecting ?? true;\n        });\n        visibility.observe(root);\n\n        // The idle spin. It waits a moment after any touch, then eases up to speed.\n        const spin = (_time: number, deltaMs: number) => {\n          const seconds = Math.min(deltaMs, 100) / 1000;\n          const busy = pressed || drag.isThrowing || heading !== null || !onScreen || (hovering && live.current.pauseOnHover);\n          if (busy) {\n            idleSeconds = 0;\n            return;\n          }\n          idleSeconds += seconds;\n          const speed = live.current.autoSpin;\n          if (reduce || speed === 0) return;\n          const ramp = Math.min(1, Math.max(0, (idleSeconds - 0.8) / 1.5));\n          setRotation(rotationNow() + speed * seconds * ramp * ramp * (3 - 2 * ramp));\n          keepUpright();\n        };\n        gsap.ticker.add(spin);\n\n        return () => {\n          gsap.ticker.remove(spin);\n          visibility.disconnect();\n          root.removeEventListener(\"wheel\", onWheel);\n          root.removeEventListener(\"keydown\", onKeyDown);\n          ring.removeEventListener(\"pointerover\", onOver);\n          root.removeEventListener(\"pointerleave\", onLeave);\n          drag.kill();\n          gsap.killTweensOf(ring);\n        };\n      });\n\n      return () => mm.revert();\n    },\n    { scope: rootRef, dependencies: [count, tileMode, snap, touchScroll], revertOnUpdate: true },\n  );\n\n  // 3. The way in: the ring unwinds into place while the photos pop up one by one\n  useGSAP(\n    () => {\n      const ring = ringRef.current;\n      const tiles = tileRefs.current.slice(0, count).filter((tile): tile is HTMLDivElement => tile !== null);\n      if (!intro || !ring || tiles.length === 0) return;\n      const mm = gsap.matchMedia();\n      mm.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        gsap.from(ring, { rotation: -140, duration: 2.4, ease: \"expo.out\" });\n        gsap.from(tiles, { scale: 0.3, opacity: 0, duration: 1.1, stagger: 0.07, ease: \"back.out(1.5)\" });\n      });\n      return () => {\n        mm.revert();\n        // Undoing the intro puts the ring back where it began, so hand it the turn it had\n        gsap.set(ring, { rotation: savedRotation.current });\n      };\n    },\n    { scope: rootRef, dependencies: [count, intro], revertOnUpdate: true },\n  );\n\n  const half = circleSize / 2;\n  const circle = `M${circleSize},${half} A${half},${half} 0 1 1 0,${half} A${half},${half} 0 1 1 ${circleSize},${half}Z`;\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"group\"\n      aria-roledescription=\"rotating gallery\"\n      aria-label={label}\n      aria-keyshortcuts=\"ArrowLeft ArrowRight\"\n      tabIndex={0}\n      className={`relative h-full w-full overflow-hidden select-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-current ${className}`}\n      style={{ containerType: \"size\", touchAction: touchScroll ? \"pan-y\" : \"none\" }}\n    >\n      <div className=\"absolute inset-0 flex items-center justify-center\">\n        {/* The ring: a square as big as the box allows. Rotating it turns every photo. */}\n        <div ref={ringRef} role=\"list\" className=\"relative shrink-0\" style={{ width: \"min(100cqw, 100cqh)\", height: \"min(100cqw, 100cqh)\" }}>\n          <svg\n            viewBox={`0 0 ${circleSize} ${circleSize}`}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute opacity-0\"\n            style={{ width: `${ringSize}%`, height: `${ringSize}%`, left: `${(100 - ringSize) / 2}%`, top: `${(100 - ringSize) / 2}%` }}\n          >\n            <path ref={pathRef} d={circle} fill=\"none\" stroke=\"none\" />\n          </svg>\n\n          {images.map((image, index) => (\n            <div\n              key={`${index}-${image.src}`}\n              ref={(tile) => {\n                tileRefs.current[index] = tile;\n              }}\n              role=\"listitem\"\n              className=\"group absolute top-0 left-0\"\n              style={{ width: `${width}%`, aspectRatio: `1 / ${ratio}`, visibility: \"hidden\", willChange: \"transform\" }}\n            >\n              <div\n                className=\"h-full w-full overflow-hidden shadow-[0_18px_38px_-18px_rgba(0,0,0,0.45)] outline-1 -outline-offset-1 outline-black/10 transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.06] dark:outline-white/10\"\n                style={{ borderRadius: radius }}\n              >\n                <img\n                  src={image.src}\n                  alt={image.alt}\n                  width={imageSize}\n                  height={Math.round(imageSize * ratio)}\n                  loading={index < 3 ? \"eager\" : \"lazy\"}\n                  decoding=\"async\"\n                  draggable={false}\n                  className=\"block h-full w-full object-cover object-[50%_25%]\"\n                />\n              </div>\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n"}],"categories":["gallery","gsap","draggable","motionpath","inertia","ring","carousel","images"],"docs":"import { ImageGallery } from \"@/components/dimaac/image-gallery\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/image-gallery","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/image-gallery","credits":[{"by":"SHIHO","url":"https://www.lummi.ai/photo/vibrant-woman-portrait-htofy","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Shaun Duval","url":"https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"West Kast","url":"https://www.lummi.ai/photo/futuristic-gold-profile-jnio5","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Lloyd Creates","url":"https://www.lummi.ai/photo/poised-portrait-ooi9b","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Amino","url":"https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"West Kast","url":"https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Semihan Dosunmu","url":"https://www.lummi.ai/photo/avant-garde-fashion-pose-1yqoo","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Semihan Dosunmu","url":"https://www.lummi.ai/photo/chic-fashion-portrait-wojpk","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"studiomiklus","url":"https://www.lummi.ai/photo/striking-portrait-jodiu","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Bernardo Lickfold","url":"https://www.lummi.ai/photo/portrait-through-orange-frame-kld5i","what":"Photo","source":"Lummi","licence":"Lummi License"}]}}