Hover Spread Gallery
Original · freeA tight row of overlapping images that fans and spreads apart, lifting the hovered one.
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-spread.json"use client";
import { useCallback, useEffect, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
type Photo = {
src: string;
title: string;
category: string;
alt: string;
};
const PHOTOS: Photo[] = [
{
src: "/img/gallery/g03.webp",
title: "Coastal Light",
category: "Seascape",
alt: "Pale morning light spreading over a calm coastline",
},
{
src: "/img/gallery/g07.webp",
title: "Studio 04",
category: "Portrait",
alt: "Soft studio portrait lit from a single window source",
},
{
src: "/img/gallery/g12.webp",
title: "Golden Hour",
category: "Landscape",
alt: "Warm low sun raking across an open field at dusk",
},
{
src: "/img/gallery/g18.webp",
title: "Quiet Harbor",
category: "Travel",
alt: "Still harbor water reflecting moored boats at first light",
},
{
src: "/img/gallery/g22.webp",
title: "Wildflowers",
category: "Nature",
alt: "Cluster of wildflowers backlit against soft green foliage",
},
{
src: "/img/gallery/g27.webp",
title: "City Grain",
category: "Street",
alt: "High-contrast street scene with long afternoon shadows",
},
{
src: "/img/gallery/g31.webp",
title: "Still Life",
category: "Studio",
alt: "Minimal still life arrangement on a textured surface",
},
{
src: "/img/gallery/g34.webp",
title: "Alpine Fog",
category: "Mountains",
alt: "Layered mountain ridges fading into low alpine fog",
},
];
const SPRING = { type: "spring" as const, stiffness: 260, damping: 26, mass: 0.7 };
export default function GalleryHoverSpread() {
const [active, setActive] = useState<number | null>(null);
const [lightbox, setLightbox] = useState<number | null>(null);
const center = (PHOTOS.length - 1) / 2;
const closeLightbox = useCallback(() => setLightbox(null), []);
const step = useCallback((dir: number) => {
setLightbox((prev) =>
prev === null ? prev : (prev + dir + PHOTOS.length) % PHOTOS.length
);
}, []);
useEffect(() => {
if (lightbox === null) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeLightbox();
else if (e.key === "ArrowRight") step(1);
else if (e.key === "ArrowLeft") step(-1);
};
window.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [lightbox, closeLightbox, step]);
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-slate-100 px-6 py-24 sm:py-28 dark:from-neutral-950 dark:to-neutral-900">
<style>{`
@keyframes ghs-breathe {
0%, 100% { opacity: 0.55; transform: translateY(0); }
50% { opacity: 1; transform: translateY(-2px); }
}
.ghs-hint { animation: ghs-breathe 3.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.ghs-hint { animation: none; opacity: 0.85; }
}
`}</style>
<div className="relative z-10 mx-auto max-w-5xl text-center">
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-indigo-600 dark:text-indigo-400">
Collection
</p>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
A Selected Hand
</h2>
<p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-slate-600 dark:text-neutral-400">
Eight frames held close, dealt like cards. Hover to fan the set apart
and lift a single frame into focus.
</p>
</div>
{/* The fanned hand */}
<div
className="relative z-10 mx-auto mt-16 flex max-w-5xl items-end justify-center px-4 pb-8 pt-12"
onMouseLeave={() => setActive(null)}
>
{PHOTOS.map((photo, i) => {
const rel = i - center;
const isActive = active === i;
const spread = active !== null;
let x = 0;
if (spread) {
x = rel * 42;
if (i < active!) x -= 30;
else if (i > active!) x += 30;
}
const rotate = isActive
? 0
: spread
? rel * 8
: rel * 4;
const y = isActive
? -52
: spread
? Math.abs(rel) * 12
: Math.abs(rel) * 6;
const scale = isActive ? 1.14 : 1;
return (
<motion.button
key={photo.src}
type="button"
className="group relative -ml-16 shrink-0 cursor-pointer rounded-2xl outline-none first:ml-0 focus-visible:ring-4 focus-visible:ring-indigo-500/60 sm:-ml-24 md:-ml-28"
style={{
zIndex: isActive ? 100 : i,
transformOrigin: "bottom center",
marginLeft: i === 0 ? 0 : undefined,
}}
animate={{ x, y, rotate, scale }}
transition={SPRING}
onMouseEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onBlur={() => setActive(null)}
onClick={() => setLightbox(i)}
aria-label={`View ${photo.title}, ${photo.category}`}
>
<div
className={`relative h-44 w-32 overflow-hidden rounded-2xl border border-white/70 bg-slate-200 ring-1 ring-black/5 transition-shadow duration-300 sm:h-56 sm:w-40 md:h-64 md:w-44 dark:border-white/10 dark:bg-neutral-800 ${
isActive
? "shadow-[0_30px_60px_-15px_rgba(15,23,42,0.55)] dark:shadow-[0_30px_60px_-15px_rgba(0,0,0,0.8)]"
: "shadow-[0_12px_30px_-12px_rgba(15,23,42,0.35)]"
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photo.src}
alt={photo.alt}
loading="lazy"
draggable={false}
className="h-full w-full object-cover"
/>
{/* Legibility veil for the caption */}
<div
aria-hidden="true"
className={`pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-transparent transition-opacity duration-300 ${
isActive ? "opacity-100" : "opacity-0 group-hover:opacity-90"
}`}
/>
{/* Caption */}
<motion.div
aria-hidden="true"
initial={false}
animate={{
opacity: isActive ? 1 : 0,
y: isActive ? 0 : 8,
}}
transition={{ duration: 0.25 }}
className="absolute inset-x-0 bottom-0 p-3 text-left"
>
<p className="text-[0.6rem] font-semibold uppercase tracking-[0.2em] text-indigo-200">
{photo.category}
</p>
<p className="mt-0.5 text-sm font-semibold text-white drop-shadow-sm">
{photo.title}
</p>
</motion.div>
</div>
</motion.button>
);
})}
</div>
<p className="ghs-hint relative z-10 mt-6 text-center text-xs font-medium uppercase tracking-[0.3em] text-slate-400 dark:text-neutral-500">
Hover or focus to fan out
</p>
{/* Lightbox */}
<AnimatePresence>
{lightbox !== null && (
<motion.div
className="fixed inset-0 z-[999] flex items-center justify-center bg-slate-950/80 p-4 backdrop-blur-md sm:p-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={closeLightbox}
role="dialog"
aria-modal="true"
aria-label={`${PHOTOS[lightbox].title}, enlarged`}
>
<motion.figure
key={PHOTOS[lightbox].src}
className="relative max-h-[85vh] w-full max-w-3xl overflow-hidden rounded-3xl bg-slate-900 shadow-2xl ring-1 ring-white/10"
initial={{ scale: 0.92, opacity: 0, y: 16 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.94, opacity: 0, y: 16 }}
transition={SPRING}
onClick={(e) => e.stopPropagation()}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={PHOTOS[lightbox].src}
alt={PHOTOS[lightbox].alt}
loading="lazy"
draggable={false}
className="max-h-[70vh] w-full object-contain"
/>
<figcaption className="flex items-center justify-between gap-4 border-t border-white/10 px-6 py-4">
<div className="text-left">
<p className="text-[0.65rem] font-semibold uppercase tracking-[0.25em] text-indigo-300">
{PHOTOS[lightbox].category}
</p>
<p className="mt-1 text-lg font-semibold text-white">
{PHOTOS[lightbox].title}
</p>
</div>
<span className="shrink-0 text-xs font-medium tabular-nums text-slate-400">
{lightbox + 1} / {PHOTOS.length}
</span>
</figcaption>
{/* Controls */}
<button
type="button"
onClick={() => step(-1)}
aria-label="Previous photo"
className="absolute left-3 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white outline-none backdrop-blur transition hover:bg-white/25 focus-visible:ring-4 focus-visible:ring-indigo-500/60"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M15 18l-6-6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
onClick={() => step(1)}
aria-label="Next photo"
className="absolute right-3 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white outline-none backdrop-blur transition hover:bg-white/25 focus-visible:ring-4 focus-visible:ring-indigo-500/60"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
onClick={closeLightbox}
aria-label="Close"
className="absolute right-3 top-3 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white outline-none backdrop-blur transition hover:bg-white/25 focus-visible:ring-4 focus-visible:ring-indigo-500/60"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</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.

