Web InnoventixFreeCode

Scroll Stack Gallery

Original · free

Image cards that pin and stack as you scroll, driven by scroll position.

byWeb InnoventixReact + Tailwind
scrollstackgalleryimages
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/gallery-scroll-stack.json
gallery-scroll-stack.tsx
"use client";

import { useRef } from "react";
import {
  motion,
  useScroll,
  useTransform,
  type MotionValue,
} from "motion/react";

type Shot = {
  src: string;
  alt: string;
  title: string;
  category: string;
  place: string;
  year: string;
  caption: string;
  tone: string; // tailwind gradient accent classes for the card frame glow
};

const SHOTS: Shot[] = [
  {
    src: "/img/gallery/g04.webp",
    alt: "Pale blue timber houses along a harbour at the blue hour",
    title: "Cobalt Hour",
    category: "Coastal Architecture",
    place: "Reykjavík, Iceland",
    year: "2024",
    caption:
      "Shot four minutes after the sun dropped, when the harbour still holds a little daylight and the streetlamps have just woken up.",
    tone: "from-indigo-400/60 via-sky-300/40 to-violet-400/60",
  },
  {
    src: "/img/gallery/g11.webp",
    alt: "Rows of glowing paper lanterns strung above a narrow evening street",
    title: "Paper Lanterns",
    category: "Street Life",
    place: "Kyoto, Japan",
    year: "2023",
    caption:
      "The Gion backstreets light one lantern at a time. I waited for the row to complete before the shutter.",
    tone: "from-amber-400/60 via-rose-300/40 to-amber-500/60",
  },
  {
    src: "/img/gallery/g19.webp",
    alt: "A long reading room lined with dark wooden shelves and warm lamps",
    title: "The Long Room",
    category: "Interiors",
    place: "Dublin, Ireland",
    year: "2022",
    caption:
      "Two hundred thousand volumes, one perfect axis of light. Hand-held at 1/15th, breath held longer.",
    tone: "from-amber-500/60 via-neutral-300/30 to-amber-400/60",
  },
  {
    src: "/img/gallery/g07.webp",
    alt: "Mirror-flat salt flats reflecting a pastel dawn sky",
    title: "Salt Flats at Dawn",
    category: "Landscape",
    place: "Uyuni, Bolivia",
    year: "2024",
    caption:
      "A thin film of water turns the largest salt flat on earth into a mirror. Sky below, sky above.",
    tone: "from-rose-300/60 via-indigo-200/30 to-emerald-300/60",
  },
  {
    src: "/img/gallery/g23.webp",
    alt: "A crowded spice market with baskets of deep red and ochre powders",
    title: "Market Palette",
    category: "Documentary",
    place: "Marrakech, Morocco",
    year: "2023",
    caption:
      "The vendor arranges his cones every morning like a painter reloading a palette. I bought saffron to earn the frame.",
    tone: "from-rose-400/60 via-amber-400/40 to-rose-500/60",
  },
  {
    src: "/img/gallery/g30.webp",
    alt: "A monumental concrete building with sweeping brutalist curves",
    title: "Concrete Cathedral",
    category: "Brutalism",
    place: "São Paulo, Brazil",
    year: "2022",
    caption:
      "Raw béton brut, softened only by fifty years of tropical rain. The light does the sculpting now.",
    tone: "from-zinc-400/60 via-slate-300/30 to-zinc-500/60",
  },
  {
    src: "/img/gallery/g15.webp",
    alt: "Turquoise glacial meltwater cutting through grey stone",
    title: "Meltwater",
    category: "Nature",
    place: "Patagonia, Chile",
    year: "2024",
    caption:
      "Glacier flour gives the water that impossible mint colour. It was four degrees and worth every shiver.",
    tone: "from-emerald-300/60 via-sky-300/40 to-emerald-400/60",
  },
  {
    src: "/img/gallery/g28.webp",
    alt: "A neon-lit arcade alley reflected in wet pavement at night",
    title: "Neon Arcade",
    category: "Night",
    place: "Osaka, Japan",
    year: "2023",
    caption:
      "It rained for the whole trip. The pavement paid me back by doubling every sign.",
    tone: "from-violet-400/60 via-rose-400/40 to-indigo-400/60",
  },
];

function StackCard({
  shot,
  index,
  total,
  progress,
}: {
  shot: Shot;
  index: number;
  total: number;
  progress: MotionValue<number>;
}) {
  // Cards further down the stack settle to a slightly smaller scale as the
  // ones after them slide up and overlap. Subtle so it reads as depth, not shrink.
  const targetScale = 1 - (total - 1 - index) * 0.04;
  const start = index / total;
  const scale = useTransform(progress, [start, 1], [1, targetScale]);
  const filter = useTransform(
    progress,
    [start, Math.min(start + 0.35, 1)],
    ["brightness(1)", "brightness(0.82)"],
  );

  return (
    <div className="sticky top-0 flex h-screen items-center justify-center px-4 sm:px-6">
      <motion.figure
        tabIndex={0}
        aria-label={`${shot.title}. ${shot.category}, ${shot.place}, ${shot.year}.`}
        style={{
          scale,
          filter,
          top: `calc(8vh + ${index * 1.6}rem)`,
        }}
        className="group relative flex h-[74vh] w-full max-w-5xl origin-top flex-col overflow-hidden rounded-3xl border border-slate-200/80 bg-white/90 shadow-2xl shadow-slate-900/10 outline-none ring-indigo-400/70 backdrop-blur-sm transition-shadow focus-visible:ring-4 dark:border-white/10 dark:bg-slate-900/80 dark:shadow-black/40 dark:ring-violet-400/70 md:flex-row"
      >
        {/* accent glow frame */}
        <span
          aria-hidden
          className={`pointer-events-none absolute inset-0 z-10 rounded-3xl bg-gradient-to-br ${shot.tone} opacity-0 mix-blend-overlay transition-opacity duration-500 group-hover:opacity-100 group-focus-visible:opacity-100`}
        />

        {/* image */}
        <div className="relative h-1/2 w-full overflow-hidden md:h-full md:w-[62%]">
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src={shot.src}
            alt={shot.alt}
            loading="lazy"
            draggable={false}
            className="gss-kenburns h-full w-full object-cover transition-transform duration-[1200ms] ease-out group-hover:scale-[1.04]"
          />
          <span
            aria-hidden
            className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-black/10 md:bg-gradient-to-r md:from-transparent md:via-transparent md:to-white/10 md:dark:to-slate-900/40"
          />
          <span className="absolute left-4 top-4 inline-flex items-center gap-1.5 rounded-full bg-black/45 px-3 py-1 text-[0.7rem] font-medium uppercase tracking-widest text-white backdrop-blur-md">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
            {shot.category}
          </span>
        </div>

        {/* caption panel */}
        <figcaption className="relative flex h-1/2 flex-1 flex-col justify-between gap-4 p-6 sm:p-8 md:h-full md:w-[38%] md:p-10">
          <div>
            <div className="flex items-baseline justify-between gap-4">
              <span className="font-mono text-xs tabular-nums text-indigo-500 dark:text-violet-400">
                {String(index + 1).padStart(2, "0")} / {String(total).padStart(2, "0")}
              </span>
              <span className="font-mono text-xs tabular-nums text-slate-400 dark:text-slate-500">
                {shot.year}
              </span>
            </div>
            <h3 className="mt-3 text-2xl font-semibold leading-tight tracking-tight text-slate-900 dark:text-white sm:text-3xl">
              {shot.title}
            </h3>
            <div className="mt-1 flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
              <svg
                aria-hidden
                viewBox="0 0 24 24"
                className="h-4 w-4 shrink-0"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.7"
              >
                <path d="M12 21s-7-6.3-7-11a7 7 0 0114 0c0 4.7-7 11-7 11z" />
                <circle cx="12" cy="10" r="2.5" />
              </svg>
              {shot.place}
            </div>
          </div>

          <p className="text-[0.95rem] leading-relaxed text-slate-600 dark:text-slate-300">
            {shot.caption}
          </p>

          <div className="flex items-center justify-between border-t border-slate-200/70 pt-4 dark:border-white/10">
            <span className="text-xs font-medium uppercase tracking-widest text-slate-400 dark:text-slate-500">
              Wanderframe · Series 04
            </span>
            <span
              aria-hidden
              className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 text-slate-500 transition-colors group-hover:border-indigo-400 group-hover:text-indigo-500 dark:border-white/15 dark:text-slate-400 dark:group-hover:border-violet-400 dark:group-hover:text-violet-300"
            >
              <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.8">
                <path d="M7 17L17 7M17 7H8M17 7v9" />
              </svg>
            </span>
          </div>
        </figcaption>
      </motion.figure>
    </div>
  );
}

export default function GalleryScrollStack() {
  const containerRef = useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({
    target: containerRef,
    offset: ["start start", "end end"],
  });

  const barScaleX = useTransform(scrollYProgress, [0, 1], [0, 1]);

  return (
    <section className="relative w-full bg-gradient-to-b from-slate-50 via-white to-slate-100 text-slate-900 dark:from-slate-950 dark:via-slate-900 dark:to-black dark:text-white">
      <style>{`
        @keyframes gss-kenburns {
          0%   { transform: scale(1) translate3d(0,0,0); }
          100% { transform: scale(1.06) translate3d(0,-1.2%,0); }
        }
        .gss-kenburns { animation: gss-kenburns 18s ease-in-out infinite alternate; }
        @keyframes gss-float {
          0%,100% { transform: translateY(0); }
          50%     { transform: translateY(-8px); }
        }
        .gss-float { animation: gss-float 6s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .gss-kenburns, .gss-float { animation: none !important; }
        }
      `}</style>

      {/* scroll progress bar */}
      <div className="pointer-events-none sticky top-0 z-30 h-1 w-full bg-transparent">
        <motion.div
          style={{ scaleX: barScaleX }}
          className="h-full w-full origin-left bg-gradient-to-r from-indigo-500 via-violet-500 to-emerald-400"
        />
      </div>

      {/* header */}
      <header className="relative mx-auto max-w-5xl px-6 pb-6 pt-24 text-center sm:pt-32">
        <span className="gss-float inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-4 py-1.5 text-xs font-medium uppercase tracking-[0.2em] text-slate-500 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-slate-400">
          <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
          Field Notes · 2022—2024
        </span>
        <h2 className="mt-6 text-4xl font-semibold tracking-tight sm:text-6xl">
          Wanderframe
        </h2>
        <p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-300 sm:text-lg">
          Eight frames from four years on the road. Keep scrolling — each print pins,
          settles, and lets the next one climb over it.
        </p>
        <div
          aria-hidden
          className="mx-auto mt-10 flex w-fit flex-col items-center gap-2 text-slate-400 dark:text-slate-500"
        >
          <span className="text-[0.7rem] font-medium uppercase tracking-widest">Scroll</span>
          <svg viewBox="0 0 24 24" className="h-5 w-5 gss-float" fill="none" stroke="currentColor" strokeWidth="1.8">
            <path d="M12 5v14M6 13l6 6 6-6" />
          </svg>
        </div>
      </header>

      {/* the stack */}
      <div ref={containerRef} className="relative">
        {SHOTS.map((shot, i) => (
          <StackCard
            key={shot.title}
            shot={shot}
            index={i}
            total={SHOTS.length}
            progress={scrollYProgress}
          />
        ))}
      </div>

      {/* footer */}
      <footer className="relative mx-auto max-w-5xl px-6 py-24 text-center">
        <p className="text-sm uppercase tracking-[0.25em] text-slate-400 dark:text-slate-500">
          End of series
        </p>
        <p className="mx-auto mt-4 max-w-md text-slate-600 dark:text-slate-300">
          Every frame shot on location, available light only. Prints numbered to fifty.
        </p>
      </footer>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →
Hover Caption Gallery

Hover Caption Gallery

Original

A responsive image grid where each tile subtly zooms and reveals a caption bar on hover.

Masonry Gallery

Masonry Gallery

Original

A Pinterest-style masonry gallery using CSS columns with varied-height images; tiles lift and brighten on hover.

Filterable Gallery

Filterable Gallery

Original

A filterable gallery with category tabs; clicking a tab animates the grid to show only that category.

Tilt Cards Gallery

Tilt Cards Gallery

Original

A grid of image cards that tilt in 3D toward the cursor on hover, with a soft glare.

Lightbox Gallery

Lightbox Gallery

Original

A thumbnail grid where clicking a thumb opens a full-screen lightbox with prev/next arrows and keyboard support.

Expanding Strip Gallery

Expanding Strip Gallery

Original

A horizontal strip of image panels; hovering a panel expands it and shrinks the others to reveal its caption.

Marquee Gallery

Marquee Gallery

Original

Two rows of images auto-scrolling in opposite directions, pausing on hover, with edge fade masks.

Hover Zoom Overlay Gallery

Hover Zoom Overlay Gallery

Original

A grid where each image scales up under a dark gradient overlay revealing a title and a view button on hover.

Polaroid Stack Gallery

Polaroid Stack Gallery

Original

A scattered stack of slightly-rotated polaroid photos that straighten and lift on hover.

Featured Thumbs Gallery

Featured Thumbs Gallery

Original

A large featured image with a thumbnail row; selecting a thumbnail swaps the featured image with a crossfade.

Carousel Gallery

Carousel Gallery

Original

An image carousel with arrows and dots, smooth slide transitions and autoplay that pauses on hover.

Duotone Hover Gallery

Duotone Hover Gallery

Original

A grid where images sit under a coloured duotone wash that clears to full colour on hover.