Image Lightbox Modal
Original · freeimage lightbox modal with nav
byWeb InnoventixReact + Tailwind
modalimagelightboxmodals
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/modal-image-lightbox.jsonmodal-image-lightbox.tsx
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
type Photo = {
src: string;
title: string;
location: string;
caption: string;
};
const PHOTOS: readonly Photo[] = [
{
src: "/img/gallery/g01.webp",
title: "Blue Hour Over Reine",
location: "Lofoten, Norway",
caption:
"The red rorbu cabins held the last light while the fjord went to glass. Shot at 04:12 — ISO 200, f/8, 6-second exposure on a rented tripod.",
},
{
src: "/img/gallery/g02.webp",
title: "Terraced Rice at First Light",
location: "Yuanyang, China",
caption:
"Two thousand years of hand-cut terraces catching a pink sky. The farmer who let me cross his ridge asked only that I not step on the seedlings.",
},
{
src: "/img/gallery/g03.webp",
title: "The Mirror at Dawn",
location: "Salar de Uyuni, Bolivia",
caption:
"A finger-deep layer of rainwater turns the world's largest salt flat into a 4,000 sq mile mirror. Horizon lines stop meaning anything out here.",
},
{
src: "/img/gallery/g04.webp",
title: "Cedar Steam, 6 A.M.",
location: "Hakone, Japan",
caption:
"Sulphur steam rising off the onsen valley before the first tour bus. The whole ridge smelled of hot minerals and wet cedar.",
},
{
src: "/img/gallery/g05.webp",
title: "Spice Pyramids",
location: "Marrakech, Morocco",
caption:
"A vendor in the Mellah rebuilds these cones of paprika and cumin every single morning. He let me photograph them if I bought the saffron.",
},
{
src: "/img/gallery/g06.webp",
title: "Ice Cave Cathedral",
location: "Vatnajökull, Iceland",
caption:
"Meltwater carves a new blue chamber under the glacier each winter. By March it will have collapsed and moved fifty meters downhill.",
},
{
src: "/img/gallery/g07.webp",
title: "Lantern Alley After Rain",
location: "Hoi An, Vietnam",
caption:
"Silk lanterns doubled in the wet cobblestones the moment the monsoon let up. I stood in a doorway for an hour waiting for the crowd to thin.",
},
{
src: "/img/gallery/g08.webp",
title: "Dune Ridge at Noon",
location: "Sossusvlei, Namibia",
caption:
"Big Daddy is roughly 325 meters of soft red sand. The climb up the windward spine took ninety minutes; the run down took ninety seconds.",
},
{
src: "/img/gallery/g09.webp",
title: "Highland Loch Stillness",
location: "Glencoe, Scotland",
caption:
"Not a breath of wind on the water and three sudden downpours in the same afternoon. This was the one dry frame of the day.",
},
{
src: "/img/gallery/g10.webp",
title: "Rooftop Prayer Flags",
location: "Kathmandu, Nepal",
caption:
"Strung so the wind would carry the printed mantras over the valley. The family on the roof was drying chilies on the same line.",
},
{
src: "/img/gallery/g11.webp",
title: "Cypress in the Fog",
location: "Val d'Orcia, Italy",
caption:
"A single farm road lined with cypress, half-erased by ground fog at sunrise. I have driven past it in clear weather and it is unremarkable.",
},
{
src: "/img/gallery/g12.webp",
title: "Aurora Over the Cabin",
location: "Abisko, Sweden",
caption:
"The clearest sky in Europe on a good night. The green held for twenty minutes, then the whole band pulsed violet and was gone.",
},
];
const FOCUSABLE =
'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
export default function ImageLightboxGallery() {
const reduceMotion = useReducedMotion();
const n = PHOTOS.length;
const [open, setOpen] = useState(false);
const [index, setIndex] = useState(0);
const [direction, setDirection] = useState(0);
const [loaded, setLoaded] = useState(false);
const dialogRef = useRef<HTMLDivElement | null>(null);
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const indexRef = useRef(0);
const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]);
const titleId = useId();
const captionId = useId();
useEffect(() => {
indexRef.current = index;
}, [index]);
const openAt = useCallback((i: number, el: HTMLButtonElement) => {
triggerRef.current = el;
setDirection(0);
setIndex(i);
setOpen(true);
}, []);
const close = useCallback(() => {
setOpen(false);
triggerRef.current?.focus();
}, []);
const paginate = useCallback(
(dir: number) => {
setDirection(dir);
setIndex((i) => (i + dir + n) % n);
},
[n],
);
const goTo = useCallback((next: number) => {
setDirection(next > indexRef.current ? 1 : next < indexRef.current ? -1 : 0);
setIndex(next);
}, []);
// Reset the load state whenever the active photo changes.
useEffect(() => {
setLoaded(false);
}, [index]);
// Lock body scroll while the lightbox is open.
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
// Keyboard: nav, close, and a focus trap inside the dialog.
useEffect(() => {
if (!open) return;
closeBtnRef.current?.focus();
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
close();
} else if (e.key === "ArrowRight") {
e.preventDefault();
paginate(1);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
paginate(-1);
} else if (e.key === "Home") {
e.preventDefault();
goTo(0);
} else if (e.key === "End") {
e.preventDefault();
goTo(n - 1);
} else if (e.key === "Tab") {
const dialog = dialogRef.current;
if (!dialog) return;
const nodes = Array.from(
dialog.querySelectorAll<HTMLElement>(FOCUSABLE),
).filter((el) => el.offsetParent !== null);
if (nodes.length === 0) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open, close, paginate, goTo, n]);
// Keep the active thumbnail scrolled into the filmstrip view.
useEffect(() => {
if (!open) return;
thumbRefs.current[index]?.scrollIntoView({
block: "nearest",
inline: "center",
behavior: reduceMotion ? "auto" : "smooth",
});
}, [index, open, reduceMotion]);
const active = PHOTOS[index];
const imageVariants: Variants = {
enter: (dir: number) => ({
opacity: 0,
x: reduceMotion ? 0 : dir > 0 ? 48 : dir < 0 ? -48 : 0,
}),
center: { opacity: 1, x: 0 },
exit: (dir: number) => ({
opacity: 0,
x: reduceMotion ? 0 : dir > 0 ? -48 : dir < 0 ? 48 : 0,
}),
};
const dur = reduceMotion ? 0 : 0.22;
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-stone-50 to-amber-50/40 px-5 py-20 text-zinc-900 sm:px-8 sm:py-28 dark:from-zinc-950 dark:to-zinc-900 dark:text-zinc-100">
<style>{`
@keyframes lbx-shimmer {
0% { background-position: -180% 0; }
100% { background-position: 180% 0; }
}
@keyframes lbx-kenburns {
from { transform: scale(1); }
to { transform: scale(1.05); }
}
.lbx-anim-shimmer {
background: linear-gradient(
100deg,
rgba(148,163,184,0.10) 20%,
rgba(148,163,184,0.28) 40%,
rgba(148,163,184,0.10) 60%
);
background-size: 200% 100%;
animation: lbx-shimmer 1.5s ease-in-out infinite;
}
.lbx-anim-kenburns {
animation: lbx-kenburns 14s ease-out forwards;
}
@media (prefers-reduced-motion: reduce) {
.lbx-anim-shimmer, .lbx-anim-kenburns { animation: none !important; }
}
`}</style>
{/* Decorative grain / glow, purely atmospheric */}
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 right-[-10%] h-72 w-72 rounded-full bg-amber-300/20 blur-3xl dark:bg-amber-500/10"
/>
<div className="relative mx-auto max-w-6xl">
<header className="mb-10 max-w-2xl sm:mb-14">
<p className="mb-3 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.2em] text-amber-700 dark:text-amber-400">
<span className="h-px w-8 bg-amber-500/60" />
Photo Journal · No. 07
</p>
<h2 className="font-serif text-3xl leading-tight tracking-tight text-zinc-900 sm:text-5xl dark:text-zinc-50">
Field Notes from the Long Way Round
</h2>
<p className="mt-4 text-base leading-relaxed text-zinc-600 sm:text-lg dark:text-zinc-400">
Twelve frames from a year of chasing light across four continents.
Select any photo to open it full-size — then use the arrow keys, the
on-screen controls, or the filmstrip to move between them.
</p>
</header>
{/* Gallery grid */}
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4">
{PHOTOS.map((photo, i) => (
<li key={photo.src} className={i % 5 === 0 ? "sm:row-span-2" : ""}>
<button
type="button"
onClick={(e) => openAt(i, e.currentTarget)}
aria-haspopup="dialog"
aria-label={`Open “${photo.title}”, ${photo.location}, in the lightbox`}
className="group relative block h-full w-full overflow-hidden rounded-xl bg-zinc-200 ring-1 ring-zinc-900/5 transition duration-300 hover:ring-amber-500/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-50 dark:bg-zinc-800 dark:ring-white/10 dark:focus-visible:ring-offset-zinc-950"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo.src}
alt={`${photo.title} — ${photo.location}`}
loading="lazy"
draggable={false}
className={`h-full w-full object-cover transition duration-500 will-change-transform group-hover:scale-[1.04] ${
i % 5 === 0 ? "aspect-[3/4]" : "aspect-square"
}`}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/0 to-black/0 opacity-0 transition-opacity duration-300 group-hover:opacity-100 group-focus-visible:opacity-100"
/>
<span className="pointer-events-none absolute inset-x-0 bottom-0 translate-y-2 p-3 text-left opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100">
<span className="block font-serif text-sm font-medium leading-snug text-white drop-shadow-sm">
{photo.title}
</span>
<span className="mt-0.5 block text-[11px] uppercase tracking-wider text-amber-200/90">
{photo.location}
</span>
</span>
</button>
</li>
))}
</ul>
</div>
{/* Lightbox */}
<AnimatePresence>
{open && (
<motion.div
key="lbx-backdrop"
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: dur }}
onClick={close}
>
<div
aria-hidden="true"
className="absolute inset-0 bg-zinc-950/80 backdrop-blur-sm"
/>
<motion.div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={captionId}
onClick={(e) => e.stopPropagation()}
className="relative flex max-h-[92vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800"
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.96, y: 10 }
}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.97, y: 10 }
}
transition={{ duration: dur, ease: "easeOut" }}
>
{/* Header */}
<div className="flex items-start justify-between gap-4 border-b border-zinc-200 px-4 py-3 sm:px-5 dark:border-zinc-800">
<div className="min-w-0">
<h3
id={titleId}
className="truncate font-serif text-lg font-medium text-zinc-900 sm:text-xl dark:text-zinc-50"
>
{active.title}
</h3>
<p className="mt-0.5 flex items-center gap-2 text-xs uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
<PinIcon className="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
<span className="truncate">{active.location}</span>
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
aria-live="polite"
className="rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-semibold tabular-nums text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300"
>
{index + 1}
<span className="mx-0.5 text-zinc-400 dark:text-zinc-500">
/
</span>
{n}
</span>
<button
ref={closeBtnRef}
type="button"
onClick={close}
aria-label="Close lightbox"
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50"
>
<CloseIcon className="h-5 w-5" />
</button>
</div>
</div>
{/* Stage */}
<div className="relative flex min-h-[42vh] flex-1 items-center justify-center overflow-hidden bg-zinc-100 dark:bg-black">
{!loaded && (
<div
aria-hidden="true"
className="lbx-anim-shimmer absolute inset-0"
/>
)}
<AnimatePresence initial={false} custom={direction} mode="wait">
<motion.div
key={index}
custom={direction}
variants={imageVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: dur, ease: "easeOut" }}
className="flex h-full w-full items-center justify-center"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.src}
alt={`${active.title} — ${active.caption}`}
loading="lazy"
draggable={false}
onLoad={() => setLoaded(true)}
className={`max-h-[62vh] w-auto max-w-full object-contain ${
loaded && !reduceMotion ? "lbx-anim-kenburns" : ""
}`}
/>
</motion.div>
</AnimatePresence>
{/* Prev / Next */}
<button
type="button"
onClick={() => paginate(-1)}
aria-label="Previous photo"
className="absolute left-2 top-1/2 inline-flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/85 text-zinc-800 shadow-md ring-1 ring-black/5 backdrop-blur transition hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 sm:left-4 dark:bg-zinc-900/80 dark:text-zinc-100 dark:ring-white/10 dark:hover:bg-zinc-900"
>
<ChevronIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => paginate(1)}
aria-label="Next photo"
className="absolute right-2 top-1/2 inline-flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/85 text-zinc-800 shadow-md ring-1 ring-black/5 backdrop-blur transition hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 sm:right-4 dark:bg-zinc-900/80 dark:text-zinc-100 dark:ring-white/10 dark:hover:bg-zinc-900"
>
<ChevronIcon className="h-5 w-5 rotate-180" />
</button>
</div>
{/* Footer: caption + filmstrip */}
<div className="border-t border-zinc-200 px-4 py-3 sm:px-5 dark:border-zinc-800">
<p
id={captionId}
className="mb-3 max-w-3xl text-sm leading-relaxed text-zinc-600 dark:text-zinc-300"
>
{active.caption}
</p>
<div
role="tablist"
aria-label="Photo thumbnails"
className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1"
>
{PHOTOS.map((photo, i) => {
const isActive = i === index;
return (
<button
key={photo.src}
ref={(el) => {
thumbRefs.current[i] = el;
}}
type="button"
role="tab"
aria-selected={isActive}
aria-label={`Photo ${i + 1}: ${photo.title}`}
onClick={() => goTo(i)}
className={`relative h-12 w-16 shrink-0 overflow-hidden rounded-md ring-1 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
isActive
? "ring-2 ring-amber-500"
: "opacity-60 ring-zinc-200 hover:opacity-100 dark:ring-zinc-700"
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo.src}
alt=""
loading="lazy"
draggable={false}
className="h-full w-full object-cover"
/>
</button>
);
})}
</div>
<p className="mt-3 hidden text-[11px] text-zinc-400 sm:block dark:text-zinc-500">
Tip: use{" "}
<kbd className="rounded border border-zinc-300 bg-zinc-50 px-1 font-sans dark:border-zinc-700 dark:bg-zinc-800">
←
</kbd>{" "}
<kbd className="rounded border border-zinc-300 bg-zinc-50 px-1 font-sans dark:border-zinc-700 dark:bg-zinc-800">
→
</kbd>{" "}
to browse and{" "}
<kbd className="rounded border border-zinc-300 bg-zinc-50 px-1 font-sans dark:border-zinc-700 dark:bg-zinc-800">
Esc
</kbd>{" "}
to close.
</p>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</section>
);
}
function CloseIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
function ChevronIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
<path d="m15 18-6-6 6-6" />
</svg>
);
}
function PinIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
<path d="M20 10c0 4.4-8 12-8 12s-8-7.6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
);
}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 quoteSimilar components
Browse all →
Basic Modal
Originalbasic centred modal with overlay

Center Modal
Originalcentred modal with icon and actions

Form Modal
Originalmodal containing a form

Confirm Modal
Originalconfirm/destructive dialog

Alert Modal
Originalalert dialog with single action

Drawer Bottom Modal
Originalbottom sheet drawer

Side Drawer Modal
Originalright side drawer modal

Fullscreen Modal
Originalfullscreen modal

Video Modal
Originalvideo player modal

Scroll Modal
Originalmodal with long scrollable body

Steps Modal
Originalmulti-step modal flow

Command Palette Modal
Originalcommand palette modal with search

