Lightbox Gallery
Original · freeA thumbnail grid where clicking a thumb opens a full-screen lightbox with prev/next arrows and keyboard support.
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-lightbox.json"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
type Shot = {
src: string;
title: string;
category: string;
location: string;
span: string;
};
const SHOTS: Shot[] = [
{
src: "/img/gallery/g03.webp",
title: "Coastal light",
category: "Landscape",
location: "Big Sur, California",
span: "sm:col-span-2 sm:row-span-2",
},
{
src: "/img/gallery/g07.webp",
title: "Studio 04",
category: "Portrait",
location: "Lisbon Atelier",
span: "sm:row-span-2",
},
{
src: "/img/gallery/g12.webp",
title: "Quiet harvest",
category: "Nature",
location: "Tuscan Hills",
span: "",
},
{
src: "/img/gallery/g18.webp",
title: "Concrete lines",
category: "Architecture",
location: "Rotterdam",
span: "",
},
{
src: "/img/gallery/g21.webp",
title: "Morning market",
category: "Street",
location: "Marrakech Medina",
span: "sm:col-span-2",
},
{
src: "/img/gallery/g05.webp",
title: "Still water",
category: "Landscape",
location: "Lake Bled",
span: "sm:row-span-2",
},
{
src: "/img/gallery/g26.webp",
title: "Golden hour",
category: "Portrait",
location: "Andalusia",
span: "",
},
{
src: "/img/gallery/g31.webp",
title: "Desert fold",
category: "Nature",
location: "Wadi Rum",
span: "sm:col-span-2",
},
{
src: "/img/gallery/g14.webp",
title: "Glass atrium",
category: "Architecture",
location: "Copenhagen",
span: "",
},
{
src: "/img/gallery/g09.webp",
title: "Late ferry",
category: "Street",
location: "Istanbul",
span: "sm:row-span-2",
},
{
src: "/img/gallery/g33.webp",
title: "Pine ridge",
category: "Landscape",
location: "Dolomites",
span: "",
},
{
src: "/img/gallery/g22.webp",
title: "Studio 11",
category: "Portrait",
location: "Berlin Loft",
span: "sm:col-span-2",
},
];
export default function GalleryLightbox() {
const [openAt, setOpenAt] = useState<number | null>(null);
const isOpen = openAt !== null;
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const open = useCallback((index: number, el: HTMLButtonElement) => {
triggerRef.current = el;
setOpenAt(index);
}, []);
const close = useCallback(() => {
setOpenAt(null);
triggerRef.current?.focus();
}, []);
const go = useCallback((dir: 1 | -1) => {
setOpenAt((prev) => {
if (prev === null) return prev;
return (prev + dir + SHOTS.length) % SHOTS.length;
});
}, []);
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
else if (e.key === "ArrowRight") go(1);
else if (e.key === "ArrowLeft") go(-1);
};
window.addEventListener("keydown", onKey);
const { overflow } = document.body.style;
document.body.style.overflow = "hidden";
closeBtnRef.current?.focus();
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = overflow;
};
}, [isOpen, close, go]);
const active = openAt !== null ? SHOTS[openAt] : null;
return (
<section className="relative w-full overflow-hidden bg-neutral-50 px-5 py-20 text-neutral-900 sm:px-8 lg:py-28 dark:bg-neutral-950 dark:text-neutral-100">
<style>{`
@keyframes glbx-rise {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.glbx-rise { animation: glbx-rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both; }
@media (prefers-reduced-motion: reduce) {
.glbx-rise { animation: none; }
}
`}</style>
{/* Ambient wash */}
<div
aria-hidden
className="pointer-events-none absolute -top-32 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-gradient-to-r from-indigo-300/40 via-violet-300/30 to-emerald-200/40 blur-3xl dark:from-indigo-600/20 dark:via-violet-600/15 dark:to-emerald-500/10"
/>
<div className="relative mx-auto max-w-6xl">
<div className="glbx-rise mb-10 flex flex-col gap-4 sm:mb-14 sm:flex-row sm:items-end sm:justify-between">
<div>
<span className="inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-neutral-500 backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/70 dark:text-neutral-400">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Field Notes
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl lg:text-5xl">
Frames from the road
</h2>
</div>
<p className="max-w-sm text-sm leading-relaxed text-neutral-500 dark:text-neutral-400">
A wandering set of light, place and stillness. Tap any frame to view
it full-screen and step through the collection.
</p>
</div>
{/* Thumbnail grid */}
<div className="grid auto-rows-[180px] grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4">
{SHOTS.map((shot, i) => (
<motion.button
key={shot.src}
type="button"
onClick={(e) => open(i, e.currentTarget)}
initial={{ opacity: 0, y: 18 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{
duration: 0.5,
delay: (i % 4) * 0.06,
ease: [0.16, 1, 0.3, 1],
}}
aria-label={`Open ${shot.title}, ${shot.category}`}
className={`group relative overflow-hidden rounded-2xl bg-neutral-200 outline-none ring-indigo-500/70 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:bg-neutral-800 dark:ring-offset-neutral-950 ${shot.span}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={shot.src}
alt={`${shot.title} — ${shot.category} photograph taken in ${shot.location}`}
loading="lazy"
draggable={false}
className="h-full w-full object-cover transition-transform duration-[900ms] ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:scale-[1.06]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-transparent opacity-70 transition-opacity duration-500 group-hover:opacity-90" />
<div className="absolute inset-x-0 bottom-0 translate-y-1 p-3 text-left transition-transform duration-500 group-hover:translate-y-0 sm:p-4">
<p className="text-[0.65rem] font-medium uppercase tracking-[0.16em] text-white/70">
{shot.category}
</p>
<p className="mt-0.5 text-sm font-semibold text-white drop-shadow-sm sm:text-base">
{shot.title}
</p>
</div>
<span
aria-hidden
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/15 text-white opacity-0 backdrop-blur-md transition-all duration-500 group-hover:opacity-100"
>
<svg
viewBox="0 0 24 24"
fill="none"
className="h-4 w-4"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"
/>
</svg>
</span>
</motion.button>
))}
</div>
</div>
{/* Lightbox */}
<AnimatePresence>
{isOpen && active && (
<motion.div
role="dialog"
aria-modal="true"
aria-label={`${active.title}, image ${(openAt ?? 0) + 1} of ${SHOTS.length}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
className="fixed inset-0 z-50 flex flex-col bg-neutral-950/80 backdrop-blur-xl"
onClick={close}
>
{/* Top bar */}
<div
className="flex items-center justify-between px-5 py-4 sm:px-8"
onClick={(e) => e.stopPropagation()}
>
<div className="text-white">
<p className="text-[0.65rem] font-medium uppercase tracking-[0.18em] text-white/60">
{active.category} · {String((openAt ?? 0) + 1).padStart(2, "0")}
<span className="text-white/35">
{" "}
/ {String(SHOTS.length).padStart(2, "0")}
</span>
</p>
<p className="mt-0.5 text-base font-semibold sm:text-lg">
{active.title}
</p>
</div>
<button
ref={closeBtnRef}
type="button"
onClick={close}
aria-label="Close lightbox"
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white outline-none ring-white/70 transition hover:bg-white/20 focus-visible:ring-2"
>
<svg
viewBox="0 0 24 24"
fill="none"
className="h-5 w-5"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 6l12 12M18 6L6 18"
/>
</svg>
</button>
</div>
{/* Stage */}
<div
className="relative flex flex-1 items-center justify-center px-4 pb-4 sm:px-20"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => go(-1)}
aria-label="Previous image"
className="absolute left-2 z-10 flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white outline-none ring-white/70 transition hover:bg-white/20 focus-visible:ring-2 sm:left-6"
>
<svg
viewBox="0 0 24 24"
fill="none"
className="h-6 w-6"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 6l-6 6 6 6"
/>
</svg>
</button>
<AnimatePresence mode="wait">
<motion.figure
key={active.src}
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="flex max-h-full max-w-full flex-col items-center"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.src}
alt={`${active.title} — ${active.category} photograph taken in ${active.location}`}
loading="lazy"
draggable={false}
className="max-h-[72vh] w-auto max-w-full rounded-xl object-contain shadow-2xl shadow-black/50 ring-1 ring-white/10"
/>
<figcaption className="mt-4 text-center text-sm text-white/70">
{active.location}
</figcaption>
</motion.figure>
</AnimatePresence>
<button
type="button"
onClick={() => go(1)}
aria-label="Next image"
className="absolute right-2 z-10 flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white outline-none ring-white/70 transition hover:bg-white/20 focus-visible:ring-2 sm:right-6"
>
<svg
viewBox="0 0 24 24"
fill="none"
className="h-6 w-6"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 6l6 6-6 6"
/>
</svg>
</button>
</div>
{/* Thumbnail strip */}
<div
className="flex items-center justify-center gap-2 overflow-x-auto px-5 pb-6 pt-2 sm:px-8"
onClick={(e) => e.stopPropagation()}
>
{SHOTS.map((shot, i) => (
<button
key={shot.src}
type="button"
onClick={() => setOpenAt(i)}
aria-label={`View ${shot.title}`}
aria-current={i === openAt}
className={`relative h-14 w-14 shrink-0 overflow-hidden rounded-lg outline-none ring-white/80 transition focus-visible:ring-2 ${
i === openAt
? "ring-2 ring-white"
: "opacity-50 hover:opacity-90"
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={shot.src}
alt={`Thumbnail of ${shot.title}`}
loading="lazy"
draggable={false}
className="h-full w-full object-cover"
/>
</button>
))}
</div>
</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.

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.

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

