Marquee Gallery
Original · freeTwo rows of images auto-scrolling in opposite directions, pausing on hover, with edge fade masks.
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-marquee.json"use client";
import { useState } from "react";
type Shot = {
src: string;
title: string;
category: string;
alt: string;
};
const ROW_TOP: Shot[] = [
{
src: "/img/gallery/g03.webp",
title: "Coastal Light",
category: "Landscape",
alt: "Soft morning light spilling across a quiet coastline",
},
{
src: "/img/gallery/g11.webp",
title: "Studio 04",
category: "Portrait",
alt: "Studio portrait lit with a single warm key light",
},
{
src: "/img/gallery/g18.webp",
title: "Understory",
category: "Nature",
alt: "Green understory foliage catching dappled sunlight",
},
{
src: "/img/gallery/g07.webp",
title: "Concrete Grid",
category: "Architecture",
alt: "Repeating concrete facade shot from below",
},
{
src: "/img/gallery/g22.webp",
title: "Low Tide",
category: "Landscape",
alt: "Wet sand reflecting a pale sky at low tide",
},
{
src: "/img/gallery/g14.webp",
title: "Amber Hour",
category: "Street",
alt: "Amber evening light on a quiet city street",
},
];
const ROW_BOTTOM: Shot[] = [
{
src: "/img/gallery/g27.webp",
title: "Still Life 09",
category: "Object",
alt: "Minimal still life arrangement on a neutral surface",
},
{
src: "/img/gallery/g05.webp",
title: "Ridge Line",
category: "Nature",
alt: "Mountain ridge line fading into distant haze",
},
{
src: "/img/gallery/g31.webp",
title: "Glass & Steel",
category: "Architecture",
alt: "Glass and steel tower reflecting the afternoon sky",
},
{
src: "/img/gallery/g19.webp",
title: "Quiet Room",
category: "Interior",
alt: "Sunlit interior room with soft shadows on the wall",
},
{
src: "/img/gallery/g24.webp",
title: "Field Notes",
category: "Nature",
alt: "Tall grasses swaying in an open summer field",
},
{
src: "/img/gallery/g09.webp",
title: "Harbor Blue",
category: "Landscape",
alt: "Boats resting in a calm blue harbor at dawn",
},
];
function MarqueeCard({
shot,
onOpen,
}: {
shot: Shot;
onOpen: (shot: Shot) => void;
}) {
return (
<button
type="button"
onClick={() => onOpen(shot)}
aria-label={`View ${shot.title}, ${shot.category}`}
className="group relative mx-3 block h-56 w-72 shrink-0 overflow-hidden rounded-2xl bg-slate-200 shadow-sm ring-1 ring-slate-900/5 outline-none transition-shadow duration-300 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white sm:h-64 sm:w-80 dark:bg-slate-800 dark:ring-white/10 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950"
>
{/* 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 will-change-transform group-hover:scale-105"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/70 via-slate-950/10 to-transparent opacity-90 transition-opacity duration-300 group-hover:opacity-100" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 translate-y-1 p-4 text-left transition-transform duration-300 group-hover:translate-y-0">
<span className="inline-flex items-center rounded-full bg-white/15 px-2.5 py-0.5 text-[11px] font-medium tracking-wide text-white/90 uppercase backdrop-blur-sm ring-1 ring-white/20">
{shot.category}
</span>
<h3 className="mt-2 text-base font-semibold text-white drop-shadow-sm">
{shot.title}
</h3>
</div>
</button>
);
}
export default function GalleryMarquee() {
const [active, setActive] = useState<Shot | null>(null);
const topLoop = [...ROW_TOP, ...ROW_TOP];
const bottomLoop = [...ROW_BOTTOM, ...ROW_BOTTOM];
return (
<section className="relative w-full overflow-hidden bg-white px-0 py-20 sm:py-28 dark:bg-slate-950">
<style>{`
@keyframes gm_marquee_ltr {
from { transform: translate3d(-50%, 0, 0); }
to { transform: translate3d(0, 0, 0); }
}
@keyframes gm_marquee_rtl {
from { transform: translate3d(0, 0, 0); }
to { transform: translate3d(-50%, 0, 0); }
}
.gm-track {
display: flex;
width: max-content;
will-change: transform;
}
.gm-row:hover .gm-track,
.gm-row:focus-within .gm-track {
animation-play-state: paused;
}
.gm-track-top {
animation: gm_marquee_rtl 44s linear infinite;
}
.gm-track-bottom {
animation: gm_marquee_ltr 52s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.gm-track-top,
.gm-track-bottom {
animation: none;
}
.gm-row {
overflow-x: auto;
}
}
`}</style>
<div className="mx-auto mb-12 max-w-6xl px-6 sm:mb-16">
<span className="inline-flex items-center gap-2 rounded-full bg-indigo-50 px-3 py-1 text-xs font-semibold tracking-wide text-indigo-700 uppercase ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
Selected Work
</span>
<h2 className="mt-4 max-w-2xl text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
A gallery in motion
</h2>
<p className="mt-3 max-w-xl text-base text-slate-600 dark:text-slate-400">
Two rows drifting in opposite directions. Hover to pause a row, then
click any frame to open it full-size.
</p>
</div>
{/* Edge fade masks + rows */}
<div
className="relative"
style={{
WebkitMaskImage:
"linear-gradient(to right, transparent, black 6%, black 94%, transparent)",
maskImage:
"linear-gradient(to right, transparent, black 6%, black 94%, transparent)",
}}
>
<div className="gm-row group flex overflow-hidden py-2">
<div className="gm-track gm-track-top" aria-hidden="false">
{topLoop.map((shot, i) => (
<MarqueeCard key={`top-${i}`} shot={shot} onOpen={setActive} />
))}
</div>
</div>
<div className="gm-row group mt-4 flex overflow-hidden py-2">
<div className="gm-track gm-track-bottom">
{bottomLoop.map((shot, i) => (
<MarqueeCard key={`bottom-${i}`} shot={shot} onOpen={setActive} />
))}
</div>
</div>
</div>
{/* Lightbox */}
{active && (
<div
role="dialog"
aria-modal="true"
aria-label={`${active.title} enlarged`}
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 p-4 backdrop-blur-sm"
onClick={() => setActive(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setActive(null);
}}
>
<div
className="relative max-h-[88vh] w-full max-w-3xl overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-white/10 dark:bg-slate-900"
onClick={(e) => e.stopPropagation()}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.src}
alt={active.alt}
loading="lazy"
draggable={false}
className="max-h-[70vh] w-full object-contain bg-slate-100 dark:bg-slate-950"
/>
<div className="flex items-center justify-between gap-4 px-5 py-4">
<div>
<span className="text-[11px] font-medium tracking-wide text-indigo-600 uppercase dark:text-indigo-400">
{active.category}
</span>
<h3 className="mt-0.5 text-lg font-semibold text-slate-900 dark:text-white">
{active.title}
</h3>
</div>
<button
type="button"
onClick={() => setActive(null)}
aria-label="Close enlarged image"
autoFocus
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-700 outline-none transition-colors hover:bg-slate-200 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
)}
</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.

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.

Flip Cards Gallery
OriginalA grid of cards that flip in 3D on hover to reveal a caption and details on the back.

