Caption Slide Up Gallery
Original · freeA grid where a caption panel slides up from the bottom of each tile on hover.
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-caption-slide-up.json"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
type Photo = {
id: number;
src: string;
alt: string;
title: string;
category: string;
caption: string;
span: "tall" | "wide" | "square";
};
const PHOTOS: Photo[] = [
{
id: 3,
src: "/img/gallery/g03.webp",
alt: "A lone hiker on a ridgeline at dawn, silhouetted against layered orange peaks",
title: "First Light on the Ridge",
category: "Landscape",
caption: "Shot at 05:12, three hours into the ascent above Val Ferret.",
span: "tall",
},
{
id: 11,
src: "/img/gallery/g11.webp",
alt: "Steam rising from a hand-poured espresso in a ceramic cup on a marble counter",
title: "The Morning Pour",
category: "Still Life",
caption: "A single-origin Ethiopian, pulled at 92°C for a 27-second shot.",
span: "wide",
},
{
id: 18,
src: "/img/gallery/g18.webp",
alt: "Neon-lit rain-slicked alley in a night market with reflections on wet stone",
title: "Night Market, Aisle 4",
category: "Street",
caption: "Fifteen minutes after the rain stopped, before the crowds returned.",
span: "square",
},
{
id: 7,
src: "/img/gallery/g07.webp",
alt: "Close-up of a potter's hands shaping wet clay on a spinning wheel",
title: "Center the Clay",
category: "Craft",
caption: "The fourth attempt of the session — the first that finally held.",
span: "tall",
},
{
id: 24,
src: "/img/gallery/g24.webp",
alt: "Aerial view of turquoise coastline meeting white sand in soft afternoon haze",
title: "Where the Blue Turns",
category: "Aerial",
caption: "Flown at 118 metres over the Amalfi shelf on a calm September day.",
span: "wide",
},
{
id: 30,
src: "/img/gallery/g30.webp",
alt: "Portrait of an elderly craftsman laughing in his sunlit woodworking studio",
title: "Forty Years at the Bench",
category: "Portrait",
caption: "Tomas has built the same chair, better each time, since 1984.",
span: "square",
},
{
id: 15,
src: "/img/gallery/g15.webp",
alt: "Frost-covered pine forest under a pale winter sky with low golden sun",
title: "The Quiet After Snow",
category: "Landscape",
caption: "Minus fourteen degrees, and not a single footprint but ours.",
span: "tall",
},
{
id: 21,
src: "/img/gallery/g21.webp",
alt: "Overhead flat lay of fresh market vegetables arranged by colour on linen",
title: "Saturday Haul",
category: "Still Life",
caption: "Everything here travelled less than forty kilometres to the table.",
span: "wide",
},
{
id: 33,
src: "/img/gallery/g33.webp",
alt: "Long-exposure city skyline at blue hour with light trails on the bridge below",
title: "Blue Hour, Long Exposure",
category: "Cityscape",
caption: "A 30-second frame at f/11 — the bridge traffic drew the light.",
span: "square",
},
{
id: 9,
src: "/img/gallery/g09.webp",
alt: "Sunlight breaking through tall redwood trees onto a mist-filled forest floor",
title: "Cathedral of Redwoods",
category: "Landscape",
caption: "The fog burned off by nine; for one minute the light poured straight down.",
span: "tall",
},
];
const spanClass: Record<Photo["span"], string> = {
tall: "sm:row-span-2 aspect-[4/5]",
wide: "sm:col-span-2 aspect-[16/11]",
square: "aspect-square",
};
export default function GalleryCaptionSlideUp() {
const [active, setActive] = useState<number | null>(null);
const prefix = "gcsu";
const uid = useId().replace(/[:]/g, "");
const triggerRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
const activePhoto = useMemo(
() => (active === null ? null : PHOTOS.find((p) => p.id === active) ?? null),
[active]
);
const close = useCallback(() => {
const prev = active;
setActive(null);
if (prev !== null) {
requestAnimationFrame(() => triggerRefs.current.get(prev)?.focus());
}
}, [active]);
const step = useCallback(
(dir: 1 | -1) => {
setActive((cur) => {
if (cur === null) return cur;
const idx = PHOTOS.findIndex((p) => p.id === cur);
const next = (idx + dir + PHOTOS.length) % PHOTOS.length;
return PHOTOS[next].id;
});
},
[]
);
useEffect(() => {
if (active === null) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
else if (e.key === "ArrowRight") {
e.preventDefault();
step(1);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
step(-1);
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [active, close, step]);
return (
<section
className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-5 py-20 sm:px-8 sm:py-28 dark:from-zinc-950 dark:via-zinc-950 dark:to-black"
aria-labelledby={`${uid}-heading`}
>
<style>{`
.${prefix}-${uid}-tile {
opacity: 0;
animation: ${prefix}-${uid}-fade 0.7s cubic-bezier(0.22,1,0.36,1) forwards;
}
@keyframes ${prefix}-${uid}-fade {
from { opacity: 0; transform: translateY(22px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.${prefix}-${uid}-panel {
transform: translateY(101%);
transition: transform 0.5s cubic-bezier(0.16,1,0.3,1);
will-change: transform;
}
.${prefix}-${uid}-tile:hover .${prefix}-${uid}-panel,
.${prefix}-${uid}-trigger:focus-visible + div .${prefix}-${uid}-panel,
.${prefix}-${uid}-tile:focus-within .${prefix}-${uid}-panel {
transform: translateY(0%);
}
.${prefix}-${uid}-tag {
transform: translateY(-6px);
opacity: 0;
transition: transform 0.45s cubic-bezier(0.16,1,0.3,1) 0.06s, opacity 0.45s ease 0.06s;
}
.${prefix}-${uid}-tile:hover .${prefix}-${uid}-tag,
.${prefix}-${uid}-tile:focus-within .${prefix}-${uid}-tag {
transform: translateY(0);
opacity: 1;
}
.${prefix}-${uid}-img {
transition: transform 0.7s cubic-bezier(0.16,1,0.3,1), filter 0.5s ease;
}
.${prefix}-${uid}-tile:hover .${prefix}-${uid}-img,
.${prefix}-${uid}-tile:focus-within .${prefix}-${uid}-img {
transform: scale(1.06);
}
@media (prefers-reduced-motion: reduce) {
.${prefix}-${uid}-tile { opacity: 1; animation: none; }
.${prefix}-${uid}-panel { transition: none; }
.${prefix}-${uid}-tag { transition: none; }
.${prefix}-${uid}-img { transition: none; }
.${prefix}-${uid}-tile:hover .${prefix}-${uid}-img,
.${prefix}-${uid}-tile:focus-within .${prefix}-${uid}-img { transform: none; }
}
`}</style>
<div className="relative z-10 mx-auto max-w-7xl">
<header className="mx-auto mb-14 max-w-2xl text-center sm:mb-20">
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-200/70 bg-indigo-50 px-3.5 py-1.5 text-xs font-medium uppercase tracking-[0.18em] text-indigo-700 dark:border-indigo-400/20 dark:bg-indigo-500/10 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
Field Notes
</span>
<h2
id={`${uid}-heading`}
className="mt-6 text-balance text-4xl font-semibold tracking-tight text-slate-900 sm:text-5xl dark:text-white"
>
A year, ten frames
</h2>
<p className="mt-4 text-pretty text-base leading-relaxed text-slate-600 sm:text-lg dark:text-zinc-400">
Hover any photograph — or focus it with the keyboard — to read the
story that slides up from below. Open one to view it full-size.
</p>
</header>
<div className="grid auto-rows-[minmax(0,1fr)] grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-5 lg:grid-cols-3">
{PHOTOS.map((photo, i) => (
<figure
key={photo.id}
className={`${prefix}-${uid}-tile group relative overflow-hidden rounded-2xl bg-slate-200 shadow-sm ring-1 ring-slate-900/5 transition-shadow duration-500 hover:shadow-2xl hover:shadow-slate-900/10 dark:bg-zinc-900 dark:ring-white/10 dark:hover:shadow-black/40 ${spanClass[photo.span]}`}
style={{ animationDelay: `${Math.min(i * 70, 560)}ms` }}
>
<button
ref={(el) => {
if (el) triggerRefs.current.set(photo.id, el);
else triggerRefs.current.delete(photo.id);
}}
type="button"
onClick={() => setActive(photo.id)}
className={`${prefix}-${uid}-trigger absolute inset-0 z-20 h-full w-full cursor-zoom-in rounded-2xl outline-none ring-indigo-500 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950`}
aria-label={`Open “${photo.title}”, ${photo.category}. ${photo.caption}`}
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo.src}
alt={photo.alt}
loading="lazy"
draggable={false}
className={`${prefix}-${uid}-img absolute inset-0 h-full w-full object-cover`}
/>
{/* base gradient for legibility even before hover */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-2/5 bg-gradient-to-t from-black/40 to-transparent opacity-70 transition-opacity duration-500 group-hover:opacity-0" />
{/* floating category chip */}
<figcaption className="pointer-events-none absolute left-4 top-4 z-10">
<span
className={`${prefix}-${uid}-tag inline-flex items-center rounded-full bg-white/85 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-slate-800 shadow-sm backdrop-blur-sm dark:bg-zinc-950/70 dark:text-zinc-100`}
>
{photo.category}
</span>
</figcaption>
{/* slide-up caption panel */}
<div
className={`${prefix}-${uid}-panel pointer-events-none absolute inset-x-0 bottom-0 z-10 bg-gradient-to-t from-slate-950 via-slate-950/95 to-slate-950/70 px-5 pb-5 pt-6 sm:px-6 sm:pb-6`}
>
<h3 className="text-lg font-semibold leading-tight text-white">
{photo.title}
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-slate-300">
{photo.caption}
</p>
<span className="mt-3 inline-flex items-center gap-1.5 text-xs font-medium text-indigo-300">
View full frame
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M7 17 17 7" />
<path d="M8 7h9v9" />
</svg>
</span>
</div>
</figure>
))}
</div>
</div>
<AnimatePresence>
{activePhoto && (
<motion.div
key="lightbox"
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.2 }}
role="dialog"
aria-modal="true"
aria-label={`${activePhoto.title}, ${activePhoto.category}`}
>
<button
type="button"
onClick={close}
aria-label="Close image viewer"
className="absolute inset-0 h-full w-full cursor-zoom-out bg-slate-950/80 backdrop-blur-md"
/>
<motion.figure
key={activePhoto.id}
className="relative z-10 flex max-h-full w-full max-w-4xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-white/10 dark:bg-zinc-900"
initial={{ opacity: 0, scale: 0.94, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ type: "spring", stiffness: 260, damping: 26 }}
>
<div className="relative flex min-h-0 flex-1 items-center justify-center bg-slate-100 dark:bg-black">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={activePhoto.src}
alt={activePhoto.alt}
loading="lazy"
draggable={false}
className="max-h-[70vh] w-full object-contain"
/>
<button
type="button"
onClick={() => step(-1)}
aria-label="Previous image"
className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full bg-white/90 p-2.5 text-slate-800 shadow-md outline-none ring-indigo-500 transition hover:bg-white focus-visible:ring-2 dark:bg-zinc-950/80 dark:text-zinc-100 dark:hover:bg-zinc-950"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<button
type="button"
onClick={() => step(1)}
aria-label="Next image"
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-white/90 p-2.5 text-slate-800 shadow-md outline-none ring-indigo-500 transition hover:bg-white focus-visible:ring-2 dark:bg-zinc-950/80 dark:text-zinc-100 dark:hover:bg-zinc-950"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
<button
type="button"
onClick={close}
aria-label="Close image viewer"
className="absolute right-3 top-3 rounded-full bg-white/90 p-2 text-slate-800 shadow-md outline-none ring-indigo-500 transition hover:bg-white focus-visible:ring-2 dark:bg-zinc-950/80 dark:text-zinc-100 dark:hover:bg-zinc-950"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
<figcaption className="flex items-start justify-between gap-4 border-t border-slate-200 px-6 py-5 dark:border-white/10">
<div>
<span className="text-[11px] font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
{activePhoto.category}
</span>
<h3 className="mt-1 text-xl font-semibold text-slate-900 dark:text-white">
{activePhoto.title}
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-slate-600 dark:text-zinc-400">
{activePhoto.caption}
</p>
</div>
<span className="shrink-0 rounded-lg bg-slate-100 px-2.5 py-1 text-xs font-medium tabular-nums text-slate-500 dark:bg-white/5 dark:text-zinc-400">
{PHOTOS.findIndex((p) => p.id === activePhoto.id) + 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.

