Web InnoventixFreeCode

Parallax Columns Gallery

Original · free

Image columns that drift at different speeds as you scroll, with a lightbox.

byWeb InnoventixReact + Tailwind
parallaxcolumnsgalleryimages
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-parallax-columns.json
gallery-parallax-columns.tsx
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
  motion,
  useScroll,
  useTransform,
  useReducedMotion,
  AnimatePresence,
  type MotionValue,
} from "motion/react";

type Shot = {
  src: string;
  alt: string;
  title: string;
  category: string;
  place: string;
  year: string;
  caption: string;
  ratio: "portrait" | "landscape" | "square";
  accent: string; // tailwind gradient classes for the caption chip glow
};

const SHOTS: Shot[] = [
  {
    src: "/img/gallery/g02.webp",
    alt: "Long escalators descending into a bright underground transit hall",
    title: "Descent",
    category: "Transit",
    place: "Berlin, Germany",
    year: "2024",
    caption:
      "Rush hour had thinned to a trickle. I held the frame until a single figure entered the well of light.",
    ratio: "portrait",
    accent: "from-indigo-400/70 to-violet-500/70",
  },
  {
    src: "/img/gallery/g07.webp",
    alt: "Terracotta rooftops of an old town under a warm afternoon haze",
    title: "Terracotta",
    category: "Cityscape",
    place: "Bologna, Italy",
    year: "2023",
    caption:
      "Shot from a bell tower an hour before close, when the roofs turn the colour of the tiles under them.",
    ratio: "landscape",
    accent: "from-amber-400/70 to-rose-500/70",
  },
  {
    src: "/img/gallery/g14.webp",
    alt: "A lone surfer paddling across glassy grey water at first light",
    title: "First Set",
    category: "Ocean",
    place: "Ericeira, Portugal",
    year: "2024",
    caption:
      "Dawn patrol. The sea was flat as slate and every paddle stroke carried for a hundred metres.",
    ratio: "square",
    accent: "from-emerald-400/70 to-sky-500/70",
  },
  {
    src: "/img/gallery/g21.webp",
    alt: "Neon shop signs stacked above a rain-slicked night alley",
    title: "Signage",
    category: "Night",
    place: "Osaka, Japan",
    year: "2023",
    caption:
      "It had just stopped raining. Every sign came back twice as bright off the wet asphalt below.",
    ratio: "portrait",
    accent: "from-rose-400/70 to-indigo-500/70",
  },
  {
    src: "/img/gallery/g05.webp",
    alt: "A wide salt flat mirroring a pale gradient sky at dusk",
    title: "Mirror Line",
    category: "Landscape",
    place: "Uyuni, Bolivia",
    year: "2022",
    caption:
      "Two centimetres of water over the salt turns the whole plateau into a horizon you can walk across.",
    ratio: "landscape",
    accent: "from-violet-400/70 to-emerald-500/70",
  },
  {
    src: "/img/gallery/g18.webp",
    alt: "A weaver's hands passing bright thread through a wooden loom",
    title: "Warp & Weft",
    category: "Craft",
    place: "Oaxaca, Mexico",
    year: "2024",
    caption:
      "She has done this move ten thousand times. I asked her to slow down once, for the frame.",
    ratio: "square",
    accent: "from-amber-400/70 to-emerald-500/70",
  },
  {
    src: "/img/gallery/g26.webp",
    alt: "Fog rolling between dark pine ridgelines in the early morning",
    title: "Ridge Fog",
    category: "Wilderness",
    place: "Jura, Switzerland",
    year: "2023",
    caption:
      "The valley filled with cloud overnight and drained slowly as the sun climbed the far slope.",
    ratio: "portrait",
    accent: "from-sky-400/70 to-slate-500/70",
  },
  {
    src: "/img/gallery/g10.webp",
    alt: "A concrete stairwell curling upward toward a bright skylight",
    title: "Spiral",
    category: "Architecture",
    place: "Porto, Portugal",
    year: "2024",
    caption:
      "The whole staircase is poured in one continuous ribbon. I lay on the floor to find its centre.",
    ratio: "landscape",
    accent: "from-indigo-400/70 to-rose-500/70",
  },
  {
    src: "/img/gallery/g30.webp",
    alt: "A market vendor arranging pyramids of coloured spices at dawn",
    title: "Spice Table",
    category: "Market",
    place: "Marrakech, Morocco",
    year: "2022",
    caption:
      "He rebuilds every cone by hand before opening. The reds hit hardest in the low morning light.",
    ratio: "square",
    accent: "from-rose-400/70 to-amber-500/70",
  },
  {
    src: "/img/gallery/g16.webp",
    alt: "A cyclist crossing an empty plaza in long evening shadow",
    title: "Long Shadow",
    category: "Street",
    place: "Valencia, Spain",
    year: "2023",
    caption:
      "The plaza empties at siesta. One rider, one shadow twice her length, and the shutter.",
    ratio: "portrait",
    accent: "from-emerald-400/70 to-indigo-500/70",
  },
  {
    src: "/img/gallery/g33.webp",
    alt: "Aerial view of turquoise river channels braiding through pale sand",
    title: "Braided River",
    category: "Aerial",
    place: "Southland, New Zealand",
    year: "2024",
    caption:
      "Shot from a small plane with the door off. The channels rewrite themselves after every flood.",
    ratio: "landscape",
    accent: "from-sky-400/70 to-emerald-500/70",
  },
  {
    src: "/img/gallery/g23.webp",
    alt: "Steam rising off a quiet hot spring surrounded by snow",
    title: "Steam",
    category: "Nature",
    place: "Hokkaidō, Japan",
    year: "2022",
    caption:
      "Minus twelve on the bank, warm as a bath in the water. The steam never stopped moving.",
    ratio: "square",
    accent: "from-slate-400/70 to-sky-500/70",
  },
];

const RATIO_CLASS: Record<Shot["ratio"], string> = {
  portrait: "aspect-[4/5]",
  landscape: "aspect-[13/9]",
  square: "aspect-square",
};

/** Per-column drift factors — larger magnitude = faster parallax travel. */
const COLUMN_FACTORS = [0.42, 1.12, 0.72, 1.34];

function useColumnCount(): number {
  const [count, setCount] = useState(4);
  useEffect(() => {
    const sm = window.matchMedia("(min-width: 640px)");
    const lg = window.matchMedia("(min-width: 1024px)");
    const update = () => setCount(lg.matches ? 4 : sm.matches ? 3 : 2);
    update();
    sm.addEventListener("change", update);
    lg.addEventListener("change", update);
    return () => {
      sm.removeEventListener("change", update);
      lg.removeEventListener("change", update);
    };
  }, []);
  return count;
}

function ParallaxColumn({
  items,
  progress,
  factor,
  reduced,
  onOpen,
}: {
  items: { shot: Shot; index: number }[];
  progress: MotionValue<number>;
  factor: number;
  reduced: boolean;
  onOpen: (index: number) => void;
}) {
  const travel = reduced ? 0 : 90 * factor;
  const y = useTransform(progress, [0, 1], [travel, -travel]);

  return (
    <motion.div style={{ y }} className="flex flex-col gap-4 sm:gap-6 will-change-transform">
      {items.map(({ shot, index }) => (
        <button
          key={shot.src}
          type="button"
          onClick={() => onOpen(index)}
          aria-label={`Open ${shot.title} — ${shot.category}, ${shot.place}`}
          className="group relative block w-full overflow-hidden rounded-2xl bg-slate-200 text-left ring-1 ring-slate-900/10 transition-shadow duration-300 hover:shadow-2xl hover:shadow-slate-900/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:bg-slate-800 dark:ring-white/10 dark:hover:shadow-black/50 dark:focus-visible:ring-offset-slate-950"
        >
          <div className={`relative w-full ${RATIO_CLASS[shot.ratio]} overflow-hidden`}>
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              src={shot.src}
              alt={shot.alt}
              loading="lazy"
              draggable={false}
              className="h-full w-full object-cover transition-transform duration-[900ms] ease-out group-hover:scale-[1.06]"
            />
            <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/75 via-slate-950/10 to-transparent opacity-90 transition-opacity duration-300 group-hover:opacity-100" />
            <span
              className={`pointer-events-none absolute left-3 top-3 inline-flex items-center rounded-full bg-gradient-to-r ${shot.accent} px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-white shadow-sm backdrop-blur-sm`}
            >
              {shot.category}
            </span>
            <div className="absolute inset-x-0 bottom-0 translate-y-1 p-4 opacity-95 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100">
              <h3 className="text-base font-semibold leading-tight text-white drop-shadow">
                {shot.title}
              </h3>
              <p className="mt-0.5 text-xs font-medium text-slate-200/90">
                {shot.place} · {shot.year}
              </p>
            </div>
          </div>
        </button>
      ))}
    </motion.div>
  );
}

export default function GalleryParallaxColumns() {
  const sectionRef = useRef<HTMLElement>(null);
  const reduced = useReducedMotion() ?? false;
  const columnCount = useColumnCount();

  const { scrollYProgress } = useScroll({
    target: sectionRef,
    offset: ["start end", "end start"],
  });

  const columns = useMemo(() => {
    const cols: { shot: Shot; index: number }[][] = Array.from(
      { length: columnCount },
      () => [],
    );
    SHOTS.forEach((shot, index) => {
      cols[index % columnCount].push({ shot, index });
    });
    return cols;
  }, [columnCount]);

  const [active, setActive] = useState<number | null>(null);
  const closeRef = useRef<HTMLButtonElement>(null);

  const open = useCallback((index: number) => setActive(index), []);
  const close = useCallback(() => setActive(null), []);
  const step = useCallback(
    (dir: number) =>
      setActive((cur) =>
        cur === null ? cur : (cur + dir + SHOTS.length) % SHOTS.length,
      ),
    [],
  );

  useEffect(() => {
    if (active === null) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") close();
      else if (e.key === "ArrowRight") step(1);
      else if (e.key === "ArrowLeft") step(-1);
    };
    window.addEventListener("keydown", onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    closeRef.current?.focus();
    return () => {
      window.removeEventListener("keydown", onKey);
      document.body.style.overflow = prev;
    };
  }, [active, close, step]);

  const activeShot = active === null ? null : SHOTS[active];

  return (
    <section
      ref={sectionRef}
      aria-labelledby="gpc-heading"
      className="relative w-full overflow-hidden bg-slate-50 py-20 sm:py-28 dark:bg-slate-950"
    >
      <style>{`
        @keyframes gpc-float {
          0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
          50% { transform: translate3d(2%, -3%, 0) scale(1.08); }
        }
        @keyframes gpc-rise {
          from { opacity: 0; transform: translateY(14px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .gpc-orb { animation: gpc-float 18s ease-in-out infinite; }
        .gpc-orb-slow { animation: gpc-float 26s ease-in-out infinite reverse; }
        .gpc-head { animation: gpc-rise 0.7s cubic-bezier(0.22, 1, 0.36, 1) both; }
        @media (prefers-reduced-motion: reduce) {
          .gpc-orb, .gpc-orb-slow, .gpc-head { animation: none !important; }
        }
      `}</style>

      {/* ambient background accents */}
      <div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
        <div className="gpc-orb absolute -left-24 top-10 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/15" />
        <div className="gpc-orb-slow absolute -right-20 top-1/3 h-80 w-80 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-600/15" />
        <div className="gpc-orb absolute bottom-0 left-1/3 h-72 w-72 rounded-full bg-emerald-300/15 blur-3xl dark:bg-emerald-500/10" />
      </div>

      <div className="relative mx-auto max-w-7xl px-5 sm:px-8">
        <header className="gpc-head mx-auto mb-12 max-w-2xl text-center sm:mb-16">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-white/60 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-slate-600 backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-slate-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Field Notes
          </span>
          <h2
            id="gpc-heading"
            className="mt-5 text-balance text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl dark:text-white"
          >
            Frames from the road
          </h2>
          <p className="mt-4 text-pretty text-base leading-relaxed text-slate-600 sm:text-lg dark:text-slate-400">
            A rolling edit of stills gathered between assignments — scroll to let
            the columns drift, and tap any frame to read the note behind it.
          </p>
        </header>

        <div
          className="grid items-start gap-4 sm:gap-6"
          style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}
        >
          {columns.map((items, i) => (
            <ParallaxColumn
              key={i}
              items={items}
              progress={scrollYProgress}
              factor={COLUMN_FACTORS[i % COLUMN_FACTORS.length]}
              reduced={reduced}
              onOpen={open}
            />
          ))}
        </div>
      </div>

      {/* Lightbox */}
      <AnimatePresence>
        {activeShot && (
          <motion.div
            role="dialog"
            aria-modal="true"
            aria-label={`${activeShot.title}, ${activeShot.place}`}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.25 }}
            className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 p-4 backdrop-blur-md sm:p-8"
            onClick={close}
          >
            <motion.figure
              initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: 12 }}
              animate={reduced ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
              exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 8 }}
              transition={
                reduced
                  ? { duration: 0.2 }
                  : { type: "spring", stiffness: 260, damping: 28 }
              }
              onClick={(e) => e.stopPropagation()}
              className="relative flex max-h-full w-full max-w-4xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-white/10 dark:bg-slate-900"
            >
              <div className="relative flex-1 overflow-hidden bg-slate-100 dark:bg-slate-800">
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={activeShot.src}
                  alt={activeShot.alt}
                  loading="lazy"
                  draggable={false}
                  className="mx-auto max-h-[62vh] w-auto max-w-full object-contain"
                />
              </div>

              <figcaption className="flex flex-col gap-3 border-t border-slate-200 p-5 sm:flex-row sm:items-start sm:justify-between sm:p-6 dark:border-white/10">
                <div className="min-w-0">
                  <span
                    className={`inline-flex items-center rounded-full bg-gradient-to-r ${activeShot.accent} px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-white`}
                  >
                    {activeShot.category}
                  </span>
                  <h3 className="mt-2 text-xl font-bold tracking-tight text-slate-900 dark:text-white">
                    {activeShot.title}
                  </h3>
                  <p className="mt-0.5 text-sm font-medium text-slate-500 dark:text-slate-400">
                    {activeShot.place} · {activeShot.year}
                  </p>
                  <p className="mt-3 max-w-prose text-sm leading-relaxed text-slate-600 dark:text-slate-300">
                    {activeShot.caption}
                  </p>
                </div>
                <span className="shrink-0 self-start rounded-md bg-slate-100 px-2 py-1 text-xs font-semibold tabular-nums text-slate-500 dark:bg-white/10 dark:text-slate-400">
                  {String(active! + 1).padStart(2, "0")} / {String(SHOTS.length).padStart(2, "0")}
                </span>
              </figcaption>

              {/* controls */}
              <button
                ref={closeRef}
                type="button"
                onClick={close}
                aria-label="Close"
                className="absolute right-3 top-3 grid h-10 w-10 place-items-center rounded-full bg-slate-950/50 text-white backdrop-blur transition hover:bg-slate-950/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
              >
                <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round">
                  <path d="M6 6l12 12M18 6L6 18" />
                </svg>
              </button>
              <button
                type="button"
                onClick={() => step(-1)}
                aria-label="Previous photo"
                className="absolute left-3 top-1/2 grid h-11 w-11 -translate-y-1/2 place-items-center rounded-full bg-slate-950/50 text-white backdrop-blur transition hover:bg-slate-950/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
              >
                <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
                  <path d="M15 5l-7 7 7 7" />
                </svg>
              </button>
              <button
                type="button"
                onClick={() => step(1)}
                aria-label="Next photo"
                className="absolute right-3 top-1/2 grid h-11 w-11 -translate-y-1/2 place-items-center rounded-full bg-slate-950/50 text-white backdrop-blur transition hover:bg-slate-950/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-white sm:right-3"
              >
                <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
                  <path d="M9 5l7 7-7 7" />
                </svg>
              </button>
            </motion.figure>
          </motion.div>
        )}
      </AnimatePresence>
    </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.