Mosaic Gallery
Original · freeA tight, gapless mosaic of mixed-size image tiles with a hover zoom.
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-mosaic.json"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView } from "motion/react";
type Shape = "portrait" | "landscape" | "square" | "feature";
interface Photo {
src: string;
alt: string;
title: string;
category: string;
location: string;
shape: Shape;
}
const PHOTOS: Photo[] = [
{
src: "/img/gallery/g07.webp",
alt: "Sunlit atrium of a concrete brutalist library seen from the ground floor",
title: "Reading Light",
category: "Architecture",
location: "Rotterdam, NL",
shape: "feature",
},
{
src: "/img/gallery/g03.webp",
alt: "A lone hiker on a narrow ridge above a sea of morning fog",
title: "Above the Fog",
category: "Landscape",
location: "Dolomites, IT",
shape: "portrait",
},
{
src: "/img/gallery/g14.webp",
alt: "Neon reflections on wet asphalt in a quiet night market alley",
title: "After the Rain",
category: "Street",
location: "Osaka, JP",
shape: "landscape",
},
{
src: "/img/gallery/g22.webp",
alt: "Close portrait of a ceramicist with clay-dusted hands beside a wheel",
title: "The Maker",
category: "Portrait",
location: "Studio 4",
shape: "portrait",
},
{
src: "/img/gallery/g09.webp",
alt: "Overhead view of a breakfast spread on a warm oak table",
title: "Slow Morning",
category: "Still Life",
location: "Home",
shape: "square",
},
{
src: "/img/gallery/g18.webp",
alt: "Emerald terraced rice fields curving down a misted valley",
title: "Terraces",
category: "Landscape",
location: "Bali, ID",
shape: "landscape",
},
{
src: "/img/gallery/g27.webp",
alt: "Spiral staircase photographed straight down into a warm stairwell",
title: "Descent",
category: "Architecture",
location: "Lisbon, PT",
shape: "portrait",
},
{
src: "/img/gallery/g11.webp",
alt: "A vendor arranging fresh flowers under a striped awning at dawn",
title: "First Bloom",
category: "Street",
location: "Marché, FR",
shape: "square",
},
{
src: "/img/gallery/g31.webp",
alt: "Long-exposure of turquoise coastline with soft blurred surf at dusk",
title: "Blue Hour",
category: "Landscape",
location: "Amalfi, IT",
shape: "feature",
},
{
src: "/img/gallery/g05.webp",
alt: "Minimal interior corner with a single chair in raking afternoon light",
title: "Quiet Corner",
category: "Interior",
location: "Copenhagen, DK",
shape: "portrait",
},
{
src: "/img/gallery/g24.webp",
alt: "Aerial shadows of pine trees stretched across fresh snow",
title: "Long Shadows",
category: "Aerial",
location: "Hokkaido, JP",
shape: "landscape",
},
{
src: "/img/gallery/g16.webp",
alt: "Detail of woven textile threads in deep indigo and rust tones",
title: "Warp & Weft",
category: "Detail",
location: "Oaxaca, MX",
shape: "square",
},
];
const SPAN: Record<Shape, string> = {
feature: "col-span-2 row-span-2 sm:col-span-2 sm:row-span-3 lg:col-span-2 lg:row-span-3",
portrait: "col-span-1 row-span-2 sm:row-span-3 lg:row-span-3",
landscape: "col-span-2 row-span-1 sm:col-span-2 sm:row-span-2 lg:col-span-2 lg:row-span-2",
square: "col-span-1 row-span-2 sm:row-span-2 lg:row-span-2",
};
export default function GalleryMosaic() {
const [active, setActive] = useState<number | null>(null);
const closeRef = useRef<HTMLButtonElement | null>(null);
const lastFocus = useRef<HTMLElement | null>(null);
const gridRef = useRef<HTMLDivElement | null>(null);
const inView = useInView(gridRef, { once: true, margin: "-80px" });
const open = useCallback((i: number) => {
lastFocus.current = document.activeElement as HTMLElement;
setActive(i);
}, []);
const close = useCallback(() => {
setActive(null);
lastFocus.current?.focus?.();
}, []);
const step = useCallback((dir: number) => {
setActive((cur) => {
if (cur === null) return cur;
return (cur + dir + PHOTOS.length) % PHOTOS.length;
});
}, []);
useEffect(() => {
if (active === null) return;
closeRef.current?.focus();
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";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [active, close, step]);
const current = active === null ? null : PHOTOS[active];
return (
<section className="relative w-full overflow-hidden bg-white px-4 py-20 sm:px-6 sm:py-24 lg:px-8 dark:bg-neutral-950">
<style>{`
@keyframes gmmoz-rise {
from { opacity: 0; transform: translateY(22px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes gmmoz-sheen {
0% { transform: translateX(-120%) skewX(-12deg); }
100% { transform: translateX(220%) skewX(-12deg); }
}
@media (prefers-reduced-motion: reduce) {
.gmmoz-rise, .gmmoz-sheen-el { animation: none !important; }
.gmmoz-zoom { transition: none !important; }
}
`}</style>
{/* ambient wash */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-72 bg-gradient-to-b from-indigo-100/60 via-transparent to-transparent blur-2xl dark:from-indigo-500/10"
/>
<div className="mx-auto max-w-7xl">
<div className="mb-10 flex flex-col gap-5 sm:mb-14 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-2xl">
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 dark:border-indigo-400/20 dark:bg-indigo-400/10 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Field Notes · 2026
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-neutral-900 sm:text-4xl lg:text-5xl dark:text-white">
The Mosaic Archive
</h2>
<p className="mt-3 text-base leading-relaxed text-neutral-600 sm:text-lg dark:text-neutral-400">
A gapless wall of frames from twelve places, each holding still.
Hover to lean in; select any tile to open it full-frame.
</p>
</div>
<p className="shrink-0 text-sm font-medium text-neutral-400 dark:text-neutral-500">
{PHOTOS.length} photographs
</p>
</div>
<div
ref={gridRef}
className="grid auto-rows-[112px] grid-cols-2 gap-0 overflow-hidden rounded-2xl border border-neutral-200 [grid-auto-flow:dense] sm:auto-rows-[130px] sm:grid-cols-3 lg:auto-rows-[150px] lg:grid-cols-4 dark:border-neutral-800"
>
{PHOTOS.map((p, i) => (
<button
key={p.src}
type="button"
onClick={() => open(i)}
aria-label={`Open ${p.title} — ${p.category}, ${p.location}`}
style={{
animation: inView
? `gmmoz-rise 0.6s cubic-bezier(0.22,1,0.36,1) ${i * 55}ms both`
: undefined,
opacity: inView ? undefined : 0,
}}
className={`gmmoz-rise group relative isolate block overflow-hidden ring-1 ring-inset ring-white/40 outline-none focus-visible:z-20 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:ring-black/30 ${SPAN[p.shape]}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.src}
alt={p.alt}
loading="lazy"
draggable={false}
className="gmmoz-zoom h-full w-full object-cover transition-transform duration-[900ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform group-hover:scale-[1.09] group-focus-visible:scale-[1.09]"
/>
{/* darkening + sheen on hover */}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-neutral-950/80 via-neutral-950/10 to-transparent opacity-70 transition-opacity duration-500 group-hover:opacity-95 group-focus-visible:opacity-95" />
<div className="pointer-events-none absolute inset-0 overflow-hidden opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<span className="gmmoz-sheen-el absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/25 to-transparent [animation:gmmoz-sheen_1.1s_ease-out]" />
</div>
{/* caption */}
<div className="absolute inset-x-0 bottom-0 translate-y-2 p-3 opacity-0 transition-all duration-500 group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100 sm:p-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-indigo-300">
{p.category}
</p>
<p className="mt-0.5 truncate text-sm font-semibold text-white sm:text-base">
{p.title}
</p>
<p className="truncate text-xs text-white/70">{p.location}</p>
</div>
</button>
))}
</div>
</div>
<AnimatePresence>
{current && (
<motion.div
role="dialog"
aria-modal="true"
aria-label={`${current.title}, ${current.category}`}
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
>
<button
type="button"
aria-label="Close gallery"
onClick={close}
className="absolute inset-0 -z-10 cursor-default bg-neutral-950/85 backdrop-blur-md"
/>
<button
ref={closeRef}
type="button"
onClick={close}
aria-label="Close"
className="absolute right-4 top-4 z-20 rounded-full border border-white/15 bg-white/10 p-2 text-white/90 outline-none transition hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-indigo-400 sm:right-6 sm:top-6"
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" 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 z-20 -translate-y-1/2 rounded-full border border-white/15 bg-white/10 p-2.5 text-white/90 outline-none transition hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-indigo-400 sm:left-6"
>
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.8" 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 z-20 -translate-y-1/2 rounded-full border border-white/15 bg-white/10 p-2.5 text-white/90 outline-none transition hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-indigo-400 sm:right-6"
>
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 5l7 7-7 7" />
</svg>
</button>
<motion.figure
key={current.src}
initial={{ opacity: 0, scale: 0.96, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.32, ease: [0.22, 1, 0.36, 1] }}
className="relative flex max-h-[86vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl bg-neutral-900 shadow-2xl ring-1 ring-white/10"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={current.src}
alt={current.alt}
loading="lazy"
draggable={false}
className="max-h-[70vh] w-full bg-neutral-800 object-contain"
/>
<figcaption className="flex items-end justify-between gap-4 border-t border-white/10 bg-neutral-900 px-5 py-4">
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-indigo-300">
{current.category}
</p>
<p className="mt-0.5 truncate text-base font-semibold text-white">
{current.title}
</p>
<p className="truncate text-sm text-white/60">{current.location}</p>
</div>
<span className="shrink-0 rounded-full bg-white/10 px-3 py-1 text-xs font-medium tabular-nums text-white/70">
{(active ?? 0) + 1} / {PHOTOS.length}
</span>
</figcaption>
</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.

