Drawer Detail Gallery
Original · freeA grid where clicking a tile opens a side drawer with the large image and details.
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-drawer-detail.json"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useInView } from "motion/react";
type Photo = {
src: string;
alt: string;
title: string;
category: string;
location: string;
date: string;
camera: string;
caption: string;
orientation: "portrait" | "landscape" | "square";
};
const PHOTOS: Photo[] = [
{
src: "/img/gallery/g03.webp",
alt: "Fog rolling over a pine ridge at first light",
title: "Blue Hour on Kestrel Ridge",
category: "Landscape",
location: "Cascade Range, Oregon",
date: "Nov 2025",
camera: "35mm · f/8 · 1/125s · ISO 200",
caption:
"We hiked in the dark to catch the fog before the sun burned it off. It held for exactly nine minutes.",
orientation: "landscape",
},
{
src: "/img/gallery/g11.webp",
alt: "Portrait of a ceramicist holding a freshly thrown bowl",
title: "Hands That Remember the Clay",
category: "Portrait",
location: "Studio 4, Lisbon",
date: "Sep 2025",
camera: "85mm · f/1.8 · 1/200s · ISO 400",
caption:
"Ana has thrown roughly forty thousand bowls. She says the good ones still surprise her.",
orientation: "portrait",
},
{
src: "/img/gallery/g22.webp",
alt: "Overhead view of a market stall stacked with citrus",
title: "Citrus, Counted Twice",
category: "Street",
location: "Mercado Central, Valencia",
date: "Jan 2026",
camera: "50mm · f/4 · 1/320s · ISO 160",
caption:
"Shot from a footbridge above the stall. The vendor waved, then went back to stacking.",
orientation: "square",
},
{
src: "/img/gallery/g07.webp",
alt: "Neon-lit alley reflected in a rain puddle at night",
title: "The Alley Keeps Its Colors",
category: "Night",
location: "Shimokitazawa, Tokyo",
date: "Oct 2025",
camera: "24mm · f/2 · 1/60s · ISO 1600",
caption:
"Rain does half the work at night. I just waited for the puddle to go still between footsteps.",
orientation: "portrait",
},
{
src: "/img/gallery/g18.webp",
alt: "Sand dunes carved into sharp ridgelines by wind",
title: "Wind Draws Its Own Lines",
category: "Landscape",
location: "Erg Chebbi, Morocco",
date: "Mar 2025",
camera: "70mm · f/11 · 1/500s · ISO 100",
caption:
"The ridgelines redraw themselves every afternoon. This particular curve lasted one gust.",
orientation: "landscape",
},
{
src: "/img/gallery/g29.webp",
alt: "Close-up of dew beads on a spider web at sunrise",
title: "A Hundred Small Mirrors",
category: "Macro",
location: "Peak District, England",
date: "Jun 2025",
camera: "100mm macro · f/5.6 · 1/250s · ISO 320",
caption:
"Each bead holds a tiny upside-down sunrise. You only get them for about twenty minutes.",
orientation: "square",
},
{
src: "/img/gallery/g14.webp",
alt: "A lone swimmer crossing a still turquoise lake",
title: "One Swimmer, Whole Lake",
category: "Adventure",
location: "Lake Bohinj, Slovenia",
date: "Jul 2025",
camera: "135mm · f/5.6 · 1/800s · ISO 200",
caption:
"He swims it every morning before the tour boats start. The water was 14 degrees.",
orientation: "landscape",
},
{
src: "/img/gallery/g26.webp",
alt: "Weathered blue door set into a whitewashed stone wall",
title: "The Blue Door on Rua da Cal",
category: "Architecture",
location: "Óbidos, Portugal",
date: "Apr 2025",
camera: "40mm · f/6.3 · 1/200s · ISO 100",
caption:
"Seven coats of paint, the owner told me. Every winter the salt air strips one back off.",
orientation: "portrait",
},
{
src: "/img/gallery/g33.webp",
alt: "Steam rising off a hot spring against snow-covered rocks",
title: "Where the Ground Breathes",
category: "Landscape",
location: "Hveravellir, Iceland",
date: "Feb 2026",
camera: "28mm · f/8 · 1/160s · ISO 250",
caption:
"Minus eleven in the air, thirty-eight in the water. The steam never stops moving.",
orientation: "square",
},
];
export default function GalleryDrawerDetail() {
const [openIndex, setOpenIndex] = useState<number | null>(null);
const headingId = useId();
const sectionRef = useRef<HTMLElement | null>(null);
const inView = useInView(sectionRef, { once: true, margin: "-80px" });
const isOpen = openIndex !== null;
const active = openIndex !== null ? PHOTOS[openIndex] : null;
const close = useCallback(() => setOpenIndex(null), []);
const goPrev = useCallback(
() =>
setOpenIndex((i) => (i === null ? i : (i - 1 + PHOTOS.length) % PHOTOS.length)),
[],
);
const goNext = useCallback(
() => setOpenIndex((i) => (i === null ? i : (i + 1) % PHOTOS.length)),
[],
);
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
else if (e.key === "ArrowLeft") goPrev();
else if (e.key === "ArrowRight") goNext();
};
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;
};
}, [isOpen, close, goPrev, goNext]);
const drawerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (isOpen && drawerRef.current) drawerRef.current.focus();
}, [isOpen]);
return (
<section
ref={sectionRef}
aria-labelledby={headingId}
className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-5 py-20 text-slate-900 sm:px-8 lg:px-12 lg:py-28 dark:from-zinc-950 dark:via-zinc-950 dark:to-black dark:text-zinc-100"
>
<style>{`
@keyframes gdd-rise {
from { opacity: 0; transform: translateY(26px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes gdd-shimmer {
0% { background-position: -160% 0; }
100% { background-position: 260% 0; }
}
.gdd-tile {
animation: gdd-rise 0.7s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.gdd-accent {
background-image: linear-gradient(
90deg,
transparent 0%,
rgba(99, 102, 241, 0.55) 45%,
rgba(139, 92, 246, 0.55) 55%,
transparent 100%
);
background-size: 200% 100%;
animation: gdd-shimmer 4.5s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.gdd-tile { animation: none !important; }
.gdd-accent { animation: none !important; }
}
`}</style>
{/* ambient glow */}
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] max-w-full -translate-x-1/2 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/15"
/>
<div className="relative mx-auto max-w-6xl">
<header className="mb-12 max-w-2xl">
<div className="mb-4 flex items-center gap-3">
<span className="inline-flex items-center rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 uppercase dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
Field Journal
</span>
<span className="h-px w-16 gdd-accent rounded-full" aria-hidden="true" />
</div>
<h2
id={headingId}
className="text-3xl font-semibold tracking-tight text-balance sm:text-4xl lg:text-5xl"
>
Nine frames, nine mornings
</h2>
<p className="mt-4 text-base leading-relaxed text-slate-600 sm:text-lg dark:text-zinc-400">
A year of chasing light in the field. Tap any frame to slide open the
full shot and the story behind it.
</p>
</header>
{/* masonry grid */}
<ul
className="columns-1 gap-4 space-y-4 sm:columns-2 sm:gap-5 sm:space-y-5 lg:columns-3"
role="list"
>
{PHOTOS.map((photo, i) => (
<li key={photo.src} className="break-inside-avoid">
<button
type="button"
onClick={() => setOpenIndex(i)}
aria-haspopup="dialog"
aria-label={`Open details for ${photo.title}`}
className="gdd-tile group relative block w-full overflow-hidden rounded-2xl bg-slate-200 text-left ring-1 ring-slate-900/5 transition-shadow duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white hover:shadow-xl hover:shadow-slate-900/10 dark:bg-zinc-800 dark:ring-white/10 dark:focus-visible:ring-offset-zinc-950 dark:hover:shadow-black/40"
style={
inView
? { animationDelay: `${Math.min(i, 8) * 70}ms` }
: { animation: "none", opacity: 0 }
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo.src}
alt={photo.alt}
loading="lazy"
draggable={false}
className={`w-full ${
photo.orientation === "portrait"
? "aspect-[4/5]"
: photo.orientation === "landscape"
? "aspect-[13/9]"
: "aspect-square"
} object-cover transition-transform duration-[900ms] ease-out group-hover:scale-[1.05]`}
/>
<div
className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/75 via-slate-950/5 to-transparent opacity-90 transition-opacity duration-300 group-hover:opacity-100"
aria-hidden="true"
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 translate-y-1 p-4 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-[0.7rem] font-medium tracking-wide text-white uppercase backdrop-blur-sm ring-1 ring-white/20">
{photo.category}
</span>
<h3 className="mt-2 text-base font-semibold text-white drop-shadow-sm">
{photo.title}
</h3>
<p className="mt-0.5 text-xs text-white/70">{photo.location}</p>
</div>
<span
className="pointer-events-none absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/85 text-slate-900 opacity-0 shadow-sm transition-all duration-300 group-hover:opacity-100 group-focus-visible:opacity-100 dark:bg-zinc-900/85 dark:text-white"
aria-hidden="true"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="M7 17 17 7M9 7h8v8" />
</svg>
</span>
</button>
</li>
))}
</ul>
</div>
{/* drawer */}
<AnimatePresence>
{isOpen && active && (
<div className="fixed inset-0 z-50 flex justify-end" role="presentation">
<motion.div
key="backdrop"
className="absolute inset-0 bg-slate-950/55 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
onClick={close}
aria-hidden="true"
/>
<motion.div
key="panel"
ref={drawerRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-label={`${active.title} — details`}
className="relative z-10 flex h-full w-full max-w-md flex-col overflow-y-auto bg-white shadow-2xl outline-none ring-1 ring-slate-900/10 sm:max-w-lg dark:bg-zinc-950 dark:ring-white/10"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", stiffness: 320, damping: 36, mass: 0.9 }}
>
{/* header controls */}
<div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-slate-200/80 bg-white/85 px-5 py-3 backdrop-blur-md dark:border-white/10 dark:bg-zinc-950/85">
<span className="text-xs font-medium tracking-wide text-slate-500 uppercase dark:text-zinc-500">
{openIndex! + 1} / {PHOTOS.length}
</span>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={goPrev}
aria-label="Previous photo"
className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-400 dark:hover:bg-zinc-800"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
>
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<button
type="button"
onClick={goNext}
aria-label="Next photo"
className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-400 dark:hover:bg-zinc-800"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
>
<path d="m9 18 6-6-6-6" />
</svg>
</button>
<button
type="button"
onClick={close}
aria-label="Close details"
className="ml-1 flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-white transition-colors hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* content */}
<AnimatePresence mode="wait">
<motion.div
key={active.src}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
className="flex flex-1 flex-col"
>
<div className="relative overflow-hidden bg-slate-100 dark:bg-zinc-900">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.src}
alt={active.alt}
loading="lazy"
draggable={false}
className="max-h-[52vh] w-full object-cover"
/>
<span className="absolute left-4 top-4 inline-flex items-center rounded-full bg-slate-950/70 px-3 py-1 text-xs font-medium tracking-wide text-white uppercase backdrop-blur-sm ring-1 ring-white/15">
{active.category}
</span>
</div>
<div className="flex-1 px-6 py-6">
<h3 className="text-2xl font-semibold tracking-tight text-balance">
{active.title}
</h3>
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm text-slate-500 dark:text-zinc-400">
<span className="inline-flex items-center gap-1.5">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 text-indigo-500"
aria-hidden="true"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
{active.location}
</span>
<span aria-hidden="true" className="text-slate-300 dark:text-zinc-700">
•
</span>
<span>{active.date}</span>
</div>
<p className="mt-5 border-l-2 border-indigo-400 pl-4 text-base leading-relaxed text-slate-700 dark:border-indigo-500/60 dark:text-zinc-300">
{active.caption}
</p>
<dl className="mt-6 space-y-3 border-t border-slate-200 pt-6 dark:border-white/10">
<div className="flex items-start justify-between gap-4">
<dt className="text-sm font-medium text-slate-500 dark:text-zinc-500">
Alt description
</dt>
<dd className="max-w-[62%] text-right text-sm text-slate-700 dark:text-zinc-300">
{active.alt}
</dd>
</div>
<div className="flex items-start justify-between gap-4">
<dt className="text-sm font-medium text-slate-500 dark:text-zinc-500">
Capture
</dt>
<dd className="max-w-[62%] text-right font-mono text-xs text-slate-700 dark:text-zinc-300">
{active.camera}
</dd>
</div>
</dl>
<div className="mt-8 flex flex-wrap gap-3">
<a
href={active.src}
target="_blank"
rel="noopener"
className="inline-flex items-center gap-2 rounded-full bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-indigo-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950"
>
View full size
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M7 17 17 7M9 7h8v8" />
</svg>
</a>
<button
type="button"
onClick={close}
className="inline-flex items-center rounded-full border border-slate-300 px-5 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-white/15 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
Back to grid
</button>
</div>
</div>
</motion.div>
</AnimatePresence>
</motion.div>
</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.

