Parallax Columns Gallery
Original · freeImage columns that drift at different speeds as you scroll, with a lightbox.
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"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
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 quoteSimilar components
Browse all →
Hover Caption Gallery
OriginalA responsive image grid where each tile subtly zooms and reveals a caption bar on hover.

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

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

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

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

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

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

Hover Zoom Overlay Gallery
OriginalA grid where each image scales up under a dark gradient overlay revealing a title and a view button on hover.

Polaroid Stack Gallery
OriginalA scattered stack of slightly-rotated polaroid photos that straighten and lift on hover.
Featured Thumbs Gallery
OriginalA large featured image with a thumbnail row; selecting a thumbnail swaps the featured image with a crossfade.

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

Duotone Hover Gallery
OriginalA grid where images sit under a coloured duotone wash that clears to full colour on hover.

