Hover Caption Gallery
Original · freeA responsive image grid where each tile subtly zooms and reveals a caption bar 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-hover-caption.json"use client";
import { useRef } from "react";
import { motion, useInView } from "motion/react";
type Shape = "portrait" | "landscape" | "square";
interface Tile {
src: string;
alt: string;
title: string;
category: string;
shape: Shape;
}
const TILES: Tile[] = [
{
src: "/img/gallery/g03.webp",
alt: "Fog rolling over a pine ridge at first light",
title: "Morning ridge",
category: "Landscape",
shape: "portrait",
},
{
src: "/img/gallery/g11.webp",
alt: "Weathered fishing boats moored along a quiet harbour",
title: "Coastal light",
category: "Travel",
shape: "landscape",
},
{
src: "/img/gallery/g07.webp",
alt: "Overhead of a ceramic mug and folded linen on oak",
title: "Studio 04",
category: "Still life",
shape: "square",
},
{
src: "/img/gallery/g19.webp",
alt: "Dew beaded on a single fern frond in shade",
title: "Undergrowth",
category: "Nature",
shape: "portrait",
},
{
src: "/img/gallery/g24.webp",
alt: "Sunlit corner of a minimalist concrete stairwell",
title: "Grey hours",
category: "Architecture",
shape: "landscape",
},
{
src: "/img/gallery/g15.webp",
alt: "Close portrait of a woman in soft window light",
title: "Held still",
category: "Portrait",
shape: "square",
},
{
src: "/img/gallery/g28.webp",
alt: "Long dune shadows stretching across pale sand",
title: "Drift",
category: "Landscape",
shape: "portrait",
},
{
src: "/img/gallery/g32.webp",
alt: "Neon sign reflected in a rain-slicked city street",
title: "After rain",
category: "Urban",
shape: "landscape",
},
{
src: "/img/gallery/g09.webp",
alt: "Freshly cut citrus arranged on a marble slab",
title: "Zest",
category: "Food",
shape: "square",
},
{
src: "/img/gallery/g21.webp",
alt: "Snow-dusted peak catching the last of the alpenglow",
title: "Last light",
category: "Landscape",
shape: "portrait",
},
];
const SHAPE_SPAN: Record<Shape, string> = {
portrait: "row-span-2",
landscape: "row-span-1",
square: "row-span-1",
};
const SHAPE_RATIO: Record<Shape, string> = {
portrait: "aspect-[4/5]",
landscape: "aspect-[13/9]",
square: "aspect-square",
};
export default function GalleryHoverCaption() {
const gridRef = useRef<HTMLDivElement>(null);
const inView = useInView(gridRef, { once: true, margin: "-80px" });
return (
<section className="relative w-full overflow-hidden bg-neutral-50 py-20 text-neutral-900 sm:py-28 dark:bg-neutral-950 dark:text-neutral-50">
<style>{`
@keyframes ghc-shimmer {
0% { transform: translateX(-120%); }
100% { transform: translateX(120%); }
}
@media (prefers-reduced-motion: reduce) {
.ghc-shimmer { animation: none !important; }
.ghc-tile, .ghc-tile * { transition-duration: 0.001ms !important; }
}
`}</style>
{/* ambient background accents */}
<div
aria-hidden
className="pointer-events-none absolute -top-24 left-1/2 h-[28rem] w-[28rem] -translate-x-1/2 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-600/15"
/>
<div
aria-hidden
className="pointer-events-none absolute bottom-0 right-0 h-80 w-80 translate-x-1/3 translate-y-1/3 rounded-full bg-violet-300/20 blur-3xl dark:bg-violet-700/15"
/>
<div className="relative mx-auto w-full max-w-6xl px-5 sm:px-8">
<div className="mb-12 flex flex-col gap-4 sm:mb-16 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-xl">
<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-indigo-600 backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/70 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Selected works
</span>
<h2 className="mt-4 text-balance text-3xl font-semibold tracking-tight sm:text-4xl md:text-5xl">
A field of light & texture
</h2>
<p className="mt-3 text-pretty text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
Hover any frame to see it breathe. A curated set of stills across
landscape, portrait, and quiet still life.
</p>
</div>
<p className="shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-500">
{TILES.length} frames
</p>
</div>
<div
ref={gridRef}
className="grid auto-rows-[11rem] grid-cols-2 gap-3 sm:auto-rows-[13rem] sm:grid-cols-3 sm:gap-4 lg:auto-rows-[14rem] lg:grid-cols-4"
>
{TILES.map((tile, i) => (
<motion.figure
key={tile.src}
initial={{ opacity: 0, y: 26 }}
animate={inView ? { opacity: 1, y: 0 } : undefined}
transition={{
duration: 0.6,
delay: i * 0.06,
ease: [0.22, 1, 0.36, 1],
}}
tabIndex={0}
aria-label={`${tile.title} — ${tile.category}`}
className={`ghc-tile group relative overflow-hidden rounded-2xl bg-neutral-200 shadow-sm outline-none ring-1 ring-black/5 transition-shadow duration-500 hover:shadow-2xl hover:shadow-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:bg-neutral-800 dark:ring-white/10 dark:focus-visible:ring-offset-neutral-950 ${SHAPE_SPAN[tile.shape]}`}
>
{/* zoom layer */}
<div className="absolute inset-0 overflow-hidden rounded-2xl">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={tile.src}
alt={tile.alt}
loading="lazy"
draggable={false}
className={`h-full w-full scale-100 object-cover transition-transform duration-[900ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform group-hover:scale-[1.08] group-focus-visible:scale-[1.08] ${SHAPE_RATIO[tile.shape]}`}
/>
</div>
{/* shimmer sweep on hover */}
<span
aria-hidden
className="ghc-shimmer pointer-events-none absolute inset-y-0 -left-1/2 w-1/2 -skew-x-12 bg-gradient-to-r from-transparent via-white/25 to-transparent opacity-0 group-hover:opacity-100 group-hover:[animation:ghc-shimmer_1.1s_ease-out] dark:via-white/10"
/>
{/* gradient scrim */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100 group-focus-visible:opacity-100"
/>
{/* caption bar */}
<figcaption className="absolute inset-x-0 bottom-0 flex translate-y-3 items-end justify-between gap-3 p-4 opacity-0 transition-all duration-500 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100">
<span className="min-w-0">
<span className="block text-[0.65rem] font-semibold uppercase tracking-[0.2em] text-indigo-200">
{tile.category}
</span>
<span className="mt-0.5 block truncate text-base font-semibold text-white drop-shadow">
{tile.title}
</span>
</span>
<span
aria-hidden
className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-white/15 text-white ring-1 ring-white/25 backdrop-blur-sm transition-transform duration-500 group-hover:rotate-45"
>
<svg
viewBox="0 0 24 24"
fill="none"
className="h-4 w-4"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M7 17 17 7M9 7h8v8" />
</svg>
</span>
</figcaption>
</motion.figure>
))}
</div>
</div>
</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 →
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.

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

