{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scrolling-gallery","type":"registry:component","title":"Scrolling Gallery","description":"A zig-zag column of pictures that slide past each other as you scroll.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/scrolling-gallery/scrolling-gallery.tsx","type":"registry:component","target":"components/dimaac/scrolling-gallery.tsx","content":"\"use client\";\n\nimport { useRef } from \"react\";\nimport { gsap } from \"gsap\";\nimport { useGSAP } from \"@gsap/react\";\n\n// A zig-zag column of pictures that slide past each other as you scroll. Every picture has its own\n// speed: a fast one climbs the screen quicker than the page scrolls, a slow one lags behind, so\n// neighbours overlap and part again. A quick scroll also leans the pictures over (skew).\n//\n// It does NOT take over the page's scroll. The page scrolls natively; we only read where it is and\n// nudge each picture with a transform. The nudge is computed from the picture's own position, so\n// a picture sits exactly where the layout puts it when it is level with the middle of the screen\n// and drifts away from that spot as it travels. Pictures that are already on screen when the page\n// loads start at rest, so the first screen looks the way the layout was drawn.\n\nexport type ScrollingGalleryImage = {\n  /** The image address. */\n  src: string;\n  /** Alt text for the picture. Use an empty string for a purely decorative one. */\n  alt: string;\n  /** How fast this picture travels compared with the scroll. 1 moves with the page, above 1 climbs quicker, below 1 lags behind. */\n  speed?: number;\n};\n\nexport type ScrollingGalleryProps = {\n  /** The pictures, top to bottom. */\n  images: ScrollingGalleryImage[];\n  /** How strongly the pictures drift from their spot. 0 turns the parallax off, 1 is each picture's own speed, 2 doubles it. */\n  parallax?: number;\n  /** The most a picture leans, in degrees, when the page scrolls fast. 0 never leans. */\n  skew?: number;\n  /** How long a picture takes to catch up with the scroll, in seconds. A little lag feels soft. 0 follows the scroll exactly. */\n  smoothing?: number;\n  /** Width of each picture as a percent of the gallery. On a phone-wide gallery it grows to about half so the pictures stay big, and it never gets too wide for the zig-zag to fit. */\n  size?: number;\n  /** How far apart the left and right pictures sit, as a percent of a picture's width. 0 stacks them in one column. */\n  stagger?: number;\n  /** Space between the pictures in the column, in pixels. */\n  gap?: number;\n  /** Width divided by height of each picture. 1 is square, 0.8 is a 4:5 portrait. */\n  aspectRatio?: number;\n  /** The element that scrolls, or a selector for it. Leave it out to follow the window. */\n  scroller?: HTMLElement | string | null;\n  /** An id for the outer box. */\n  id?: string;\n  /** Extra classes for the outer box. */\n  className?: string;\n  /** Extra classes for each picture, e.g. a radius or a shadow. */\n  imageClassName?: string;\n};\n\nfunction resolveScroller(scroller: ScrollingGalleryProps[\"scroller\"]): HTMLElement | null {\n  if (!scroller) return null;\n  if (typeof scroller === \"string\") return document.querySelector<HTMLElement>(scroller);\n  return scroller;\n}\n\nconst clamp = (min: number, max: number, value: number) => Math.min(max, Math.max(min, value));\n\nexport function ScrollingGallery({\n  images,\n  parallax = 1,\n  skew = 8,\n  smoothing = 0.6,\n  size = 26,\n  stagger = 40,\n  gap = 0,\n  aspectRatio = 1,\n  scroller = null,\n  id,\n  className = \"\",\n  imageClassName = \"\",\n}: ScrollingGalleryProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const rowRefs = useRef<(HTMLLIElement | null)[]>([]);\n  const imageRefs = useRef<(HTMLImageElement | null)[]>([]);\n\n  // Read on every frame, so moving these sliders doesn't rebuild anything\n  const live = useRef({ parallax, skew });\n  live.current = { parallax, skew };\n  // The speeds are read when measuring and on every frame, so a changed speed lands without a rebuild\n  const speeds = useRef<number[]>([]);\n  speeds.current = images.map((image) => image.speed ?? 1);\n\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      const rows = rowRefs.current.slice(0, images.length).filter((row): row is HTMLLIElement => row !== null);\n      const pictures = imageRefs.current.slice(0, images.length).filter((image): image is HTMLImageElement => image !== null);\n      if (!root || rows.length === 0 || rows.length !== pictures.length) return;\n\n      // Reduced motion: nothing below runs, the pictures stay where the layout puts them\n      const mm = gsap.matchMedia();\n      mm.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        const scrollElement = resolveScroller(scroller);\n        const readScroll = () => (scrollElement ? scrollElement.scrollTop : window.scrollY);\n        // The visible part of whatever scrolls: its top edge and height, in window coordinates\n        const readView = () => {\n          if (!scrollElement) return { top: 0, height: window.innerHeight };\n          return { top: scrollElement.getBoundingClientRect().top, height: scrollElement.clientHeight };\n        };\n\n        const moveY = pictures.map((picture) => gsap.quickTo(picture, \"y\", { duration: Math.max(smoothing, 0.0001), ease: \"power3.out\" }));\n        const lean = gsap.quickTo(pictures, \"skewY\", { duration: 0.5, ease: \"power3.out\" });\n\n        // Where each row's middle sits in the root, and the distance from the middle of the screen\n        // at which that row is \"at rest\" (its drift is zero there). That is the middle of the screen,\n        // unless the row is already on screen when the page is at the top: then it rests where it starts.\n        let middles: number[] = [];\n        let restOffsets: number[] = [];\n\n        const measure = () => {\n          const view = readView();\n          const rootTop = root.getBoundingClientRect().top;\n          const scrollTopInContent = rootTop - view.top + readScroll();\n          middles = rows.map((row) => {\n            const box = row.getBoundingClientRect();\n            return box.top - rootTop + box.height / 2;\n          });\n          restOffsets = middles.map((middle) => {\n            const middleAtScrollZero = scrollTopInContent + middle;\n            const startsOnScreen = middleAtScrollZero >= 0 && middleAtScrollZero <= view.height;\n            return startsOnScreen ? middleAtScrollZero - view.height / 2 : 0;\n          });\n        };\n        measure();\n\n        let lastScroll = readScroll();\n        let velocity = 0; // pixels per second, smoothed. Positive means scrolling down.\n        let visible = true;\n\n        const tick = (_time: number, deltaMs: number) => {\n          const dt = Math.min(deltaMs, 50) / 1000;\n          if (dt <= 0) return;\n\n          const scrolled = readScroll();\n          const rawVelocity = (scrolled - lastScroll) / dt;\n          lastScroll = scrolled;\n          // Wheel ticks arrive in bursts, so smooth them\n          velocity += (rawVelocity - velocity) * (1 - Math.exp(-dt * 10));\n          if (!visible) return;\n\n          const { parallax: strength, skew: maxSkew } = live.current;\n          const view = readView();\n          const rootTop = root.getBoundingClientRect().top - view.top;\n\n          rows.forEach((_, index) => {\n            const fromMiddle = rootTop + middles[index]! - view.height / 2 - restOffsets[index]!;\n            // Bounded, so a picture far away never flies off\n            const travelled = clamp(-view.height, view.height, fromMiddle);\n            moveY[index]!(((speeds.current[index] ?? 1) - 1) * strength * travelled);\n          });\n\n          // Scrolling down leans the pictures one way, up the other. About 900px a second leans fully.\n          lean(clamp(-maxSkew, maxSkew, (-velocity / 900) * maxSkew));\n        };\n        gsap.ticker.add(tick);\n\n        // The gallery changing size (a resize, a new gap or size) moves every row, so measure again\n        const resizeObserver = new ResizeObserver(measure);\n        resizeObserver.observe(root);\n        window.addEventListener(\"resize\", measure);\n        // Images and fonts above the gallery can push it down after the first measure\n        window.addEventListener(\"load\", measure);\n\n        // No work while the gallery is off screen\n        const visibility = new IntersectionObserver(([entry]) => {\n          visible = entry?.isIntersecting ?? true;\n        }, { rootMargin: \"200px 0px\" });\n        visibility.observe(root);\n\n        return () => {\n          gsap.ticker.remove(tick);\n          resizeObserver.disconnect();\n          visibility.disconnect();\n          window.removeEventListener(\"resize\", measure);\n          window.removeEventListener(\"load\", measure);\n          gsap.killTweensOf(pictures);\n          gsap.set(pictures, { clearProps: \"transform\" });\n        };\n      });\n\n      return () => mm.revert();\n    },\n    { scope: rootRef, dependencies: [images.length, scroller, smoothing], revertOnUpdate: true },\n  );\n\n  // The two columns sit `stagger` percent of a picture apart, so each row moves half of that.\n  // The widest the zig-zag may make a picture is the width at which it still fits the box.\n  const widest = 100 / (1 + stagger / 100);\n  // In a box narrower than about 620px the picture grows to roughly half of it (the 4 is how fast\n  // it ramps up), so phones don't get thumbnails. In a wider box this is 0 and `size` rules.\n  const phoneFloor = \"clamp(0px, calc((620px - 100%) * 4), 52%)\";\n\n  return (\n    <div id={id} ref={rootRef} className={`relative w-full overflow-x-clip ${className}`}>\n      <ul className=\"m-0 flex list-none flex-col p-0\" style={{ rowGap: gap }}>\n        {images.map((image, index) => (\n          <li\n            key={`${index}-${image.src}`}\n            ref={(row) => {\n              rowRefs.current[index] = row;\n            }}\n            className=\"flex justify-center\"\n          >\n            <div className=\"max-w-full\" style={{ width: `min(max(${size}%, 80px, ${phoneFloor}), ${widest}%)`, transform: `translateX(${index % 2 === 0 ? stagger / 2 : -stagger / 2}%)` }}>\n              <img\n                ref={(picture) => {\n                  imageRefs.current[index] = picture;\n                }}\n                src={image.src}\n                alt={image.alt}\n                loading=\"lazy\"\n                decoding=\"async\"\n                draggable={false}\n                className={`block w-full object-cover will-change-transform ${imageClassName}`}\n                style={{ aspectRatio }}\n              />\n            </div>\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n"}],"categories":["gallery","gsap","scroll","parallax","skew","images"],"docs":"import { ScrollingGallery } from \"@/components/dimaac/scrolling-gallery\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/scrolling-gallery","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/scrolling-gallery","credits":[{"by":"West Kast","url":"https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql","what":"Fashion Portrait with Orange Visor","source":"Lummi","licence":"Lummi License"},{"by":"rena","url":"https://www.lummi.ai/photo/modern-architectural-design-aozka","what":"Modern Architectural Design","source":"Lummi","licence":"Lummi License"},{"by":"Dona Mara","url":"https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p","what":"Elegant Perfume Bottle","source":"Lummi","licence":"Lummi License"},{"by":"Pablo Stanley","url":"https://www.lummi.ai/photo/desert-dunes-and-mountain-range-bfczd","what":"Desert Dunes and Mountain Range","source":"Lummi","licence":"Lummi License"},{"by":"Amino","url":"https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow","what":"Vivid Portrait on Pink","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp","what":"Vibrant Cylindrical Arrangement","source":"Lummi","licence":"Lummi License"},{"by":"Krystyna","url":"https://www.lummi.ai/photo/moody-solitary-figure-x1avl","what":"Moody Solitary Figure","source":"Lummi","licence":"Lummi License"},{"by":"Cayetano Gros","url":"https://www.lummi.ai/photo/dramatic-coastline-aerial-view-jml-7","what":"Dramatic Coastline Aerial View","source":"Lummi","licence":"Lummi License"},{"by":"marisol","url":"https://www.lummi.ai/photo/glossy-pink-sphere-45si5","what":"Glossy Pink Sphere","source":"Lummi","licence":"Lummi License"},{"by":"Semihan Dosunmu","url":"https://www.lummi.ai/photo/avant-garde-fashion-pose-1yqoo","what":"Avant-Garde Fashion Pose","source":"Lummi","licence":"Lummi License"},{"by":"Siren Media","url":"https://www.lummi.ai/photo/minimalist-stucco-building-gurez","what":"Minimalist Stucco Building","source":"Lummi","licence":"Lummi License"},{"by":"Lloyd Creates","url":"https://www.lummi.ai/photo/poised-portrait-ooi9b","what":"Poised Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Pablo Stanley","url":"https://www.lummi.ai/photo/elegant-vase-with-dried-flowers-dhxnk","what":"Elegant Vase with Dried Flowers","source":"Lummi","licence":"Lummi License"},{"by":"PZF","url":"https://www.lummi.ai/photo/mossy-forest-scene-o4b8t","what":"Mossy Forest Scene","source":"Lummi","licence":"Lummi License"},{"by":"Mariana Pedroza","url":"https://www.lummi.ai/photo/reflective-spheres-and-books-in-a-modern-setting-U_hc_","what":"Reflective Spheres and Books in a Desert Mirage","source":"Lummi","licence":"Lummi License"},{"by":"Axel Photography","url":"https://www.lummi.ai/photo/modern-fashion-pose-zsdav","what":"Modern Fashion Pose","source":"Lummi","licence":"Lummi License"}]}}