Hexagon Gallery
Original · freeHexagonal image tiles in a honeycomb layout that scale 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-hexagon.json"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
interface HexPhoto {
src: string;
alt: string;
title: string;
category: string;
caption: string;
}
const PHOTOS: HexPhoto[] = [
{
src: "/img/gallery/g01.webp",
alt: "Dawn light spilling over a ridge of folded sandstone in the high desert",
title: "First Light, Coyote Buttes",
category: "Desert",
caption: "5:41 a.m. — the sun clears the rim and the whole wall turns to ember.",
},
{
src: "/img/gallery/g02.webp",
alt: "A lone fisherman casting into a mirror-still alpine lake at blue hour",
title: "The Patient Hour",
category: "Water",
caption: "One cast, one ripple, and forty minutes of absolute stillness.",
},
{
src: "/img/gallery/g03.webp",
alt: "Terraced rice paddies glowing green under low monsoon cloud",
title: "Stepped Fields of Bali",
category: "Landscape",
caption: "Every terrace hand-cut, flooded by gravity older than the road below.",
},
{
src: "/img/gallery/g04.webp",
alt: "Close portrait of an artisan weaver with dye-stained hands at a loom",
title: "Hands That Remember",
category: "Portrait",
caption: "Fifty years at the same loom. The pattern lives in her wrists now.",
},
{
src: "/img/gallery/g05.webp",
alt: "Neon-lit rain-slicked alley reflecting signs in a night market",
title: "Wet Neon, Kowloon",
category: "Urban",
caption: "Rain turns the pavement into a second sky of borrowed light.",
},
{
src: "/img/gallery/g06.webp",
alt: "A single autumn maple leaf frozen mid-fall against dark pine forest",
title: "The Long Descent",
category: "Nature",
caption: "Caught at 1/2000s — a single leaf suspended between two seasons.",
},
{
src: "/img/gallery/g07.webp",
alt: "Snow-dusted peaks catching alpenglow above a valley of fog",
title: "Alpenglow Over the Fog Sea",
category: "Mountains",
caption: "The valley disappears; only the summits agree to be seen.",
},
{
src: "/img/gallery/g08.webp",
alt: "Overhead view of a turquoise coastline meeting pale limestone cliffs",
title: "Edge of the Adriatic",
category: "Coast",
caption: "Shot from a cliff path where the water reads every shade of blue at once.",
},
{
src: "/img/gallery/g09.webp",
alt: "Golden dunes rippled by wind with a caravan of tiny figures crossing",
title: "Crossing the Erg",
category: "Desert",
caption: "Scale only makes sense once you find the people in the frame.",
},
{
src: "/img/gallery/g10.webp",
alt: "Bioluminescent waves breaking on a dark beach under a field of stars",
title: "Two Kinds of Starlight",
category: "Night",
caption: "The tide glowed blue for three nights, then never again that summer.",
},
{
src: "/img/gallery/g11.webp",
alt: "Street musician mid-song under warm tungsten light in an old arcade",
title: "The 11 O'Clock Set",
category: "Portrait",
caption: "He plays until the arcade lights cut out. This was the last song.",
},
];
const CLIP =
"polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)";
const ROWS: number[][] = [
[0, 1, 2, 3],
[4, 5, 6],
[7, 8, 9, 10],
];
const cellVariants: Variants = {
hidden: { opacity: 0, scale: 0.82, y: 26 },
show: (i: number) => ({
opacity: 1,
scale: 1,
y: 0,
transition: {
delay: i * 0.055,
duration: 0.55,
ease: [0.22, 1, 0.36, 1],
},
}),
};
export default function GalleryHexagon() {
const headingId = useId();
const [active, setActive] = useState<number | null>(null);
const reduce = useReducedMotion();
const triggerRefs = useRef<Array<HTMLButtonElement | null>>([]);
const lastTrigger = useRef<number | null>(null);
const open = useCallback((index: number) => {
lastTrigger.current = index;
setActive(index);
}, []);
const close = useCallback(() => {
setActive(null);
const idx = lastTrigger.current;
if (idx != null) triggerRefs.current[idx]?.focus();
}, []);
const step = useCallback((dir: 1 | -1) => {
setActive((prev) => {
if (prev == null) return prev;
return (prev + dir + PHOTOS.length) % PHOTOS.length;
});
}, []);
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);
}
};
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;
};
}, [active, close, step]);
let flatIndex = -1;
return (
<section
aria-labelledby={headingId}
className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-5 py-24 sm:px-8 lg:py-32 dark:from-zinc-950 dark:via-zinc-950 dark:to-black"
>
<style>{`
@keyframes ghx-drift {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(0, -22px, 0) scale(1.08); }
}
@keyframes ghx-drift-b {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(18px, 16px, 0) scale(1.12); }
}
.ghx-blob { animation: ghx-drift 16s ease-in-out infinite; }
.ghx-blob-b { animation: ghx-drift-b 21s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.ghx-blob, .ghx-blob-b { animation: none !important; }
}
`}</style>
{/* Decorative background */}
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="ghx-blob absolute -left-24 top-8 h-80 w-80 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
<div className="ghx-blob-b absolute -right-16 bottom-0 h-96 w-96 rounded-full bg-violet-300/40 blur-3xl dark:bg-violet-700/20" />
<div className="absolute left-1/2 top-1/3 h-72 w-72 -translate-x-1/2 rounded-full bg-emerald-200/30 blur-3xl dark:bg-emerald-700/10" />
</div>
<div className="mx-auto max-w-5xl">
<header className="mx-auto mb-16 max-w-2xl text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3.5 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-700 dark:border-indigo-500/30 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" />
Honeycomb Series
</span>
<h2
id={headingId}
className="mt-6 text-balance text-4xl font-semibold tracking-tight text-slate-900 sm:text-5xl dark:text-white"
>
Eleven frames, one hive
</h2>
<p className="mt-4 text-pretty text-base leading-relaxed text-slate-600 sm:text-lg dark:text-zinc-400">
A field notebook in glass. Hover any cell to lift it from the comb,
then open it full-frame. Everything is keyboard-navigable.
</p>
</header>
{/* Honeycomb */}
<div className="flex flex-col items-center [--hx-gap:0.5rem] [--hx-w:clamp(6.5rem,26vw,11rem)] [--hx-h:calc(var(--hx-w)*1.1547)]">
{ROWS.map((row, rowIdx) => (
<div
key={rowIdx}
className="flex justify-center gap-[var(--hx-gap)]"
style={{
marginTop: rowIdx === 0 ? 0 : "calc(var(--hx-h) * -0.245)",
marginLeft:
row.length < 4
? "calc((var(--hx-w) + var(--hx-gap)) * 0.5)"
: undefined,
}}
>
{row.map((photoIdx) => {
flatIndex += 1;
const i = flatIndex;
const photo = PHOTOS[photoIdx];
return (
<motion.div
key={photoIdx}
custom={i}
variants={cellVariants}
initial={reduce ? false : "hidden"}
whileInView="show"
viewport={{ once: true, amount: 0.35 }}
className="group relative"
style={{
width: "var(--hx-w)",
height: "var(--hx-h)",
}}
>
<button
ref={(el) => {
triggerRefs.current[photoIdx] = el;
}}
type="button"
onClick={() => open(photoIdx)}
aria-label={`Open ${photo.title} — ${photo.category}`}
className="relative block h-full w-full origin-center cursor-pointer transition-[transform,filter] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] outline-none will-change-transform hover:z-20 hover:scale-[1.14] focus-visible:z-20 focus-visible:scale-[1.14]"
style={{ clipPath: CLIP, WebkitClipPath: CLIP }}
>
{/* Grout / frame layer */}
<span
aria-hidden
className="absolute inset-0 bg-white dark:bg-zinc-800"
style={{ clipPath: CLIP, WebkitClipPath: CLIP }}
/>
{/* Image, inset by the grout width */}
<span
className="absolute inset-[3px] overflow-hidden"
style={{ clipPath: CLIP, WebkitClipPath: CLIP }}
>
{/* 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 transition-transform duration-700 ease-out group-hover:scale-110"
/>
{/* Tint + label overlay */}
<span
aria-hidden
className="absolute inset-0 bg-gradient-to-t from-slate-950/70 via-slate-950/5 to-transparent opacity-70 transition-opacity duration-500 group-hover:opacity-95"
/>
<span className="absolute inset-x-0 bottom-0 flex flex-col items-center px-3 pb-[18%] text-center">
<span className="translate-y-2 text-[0.6rem] font-semibold uppercase tracking-[0.16em] text-indigo-200 opacity-0 transition-all duration-500 group-hover:translate-y-0 group-hover:opacity-100">
{photo.category}
</span>
<span className="mt-1 line-clamp-2 translate-y-2 text-[0.8rem] font-semibold leading-tight text-white opacity-0 transition-all delay-[60ms] duration-500 group-hover:translate-y-0 group-hover:opacity-100">
{photo.title}
</span>
</span>
</span>
{/* Ring on hover/focus */}
<span
aria-hidden
className="absolute inset-0 opacity-0 ring-2 ring-inset ring-indigo-400/0 transition-opacity duration-500 group-hover:opacity-100 group-focus-visible:opacity-100"
style={{ clipPath: CLIP, WebkitClipPath: CLIP }}
>
<span
className="absolute inset-0 ring-2 ring-inset ring-indigo-400/80 dark:ring-indigo-300/80"
style={{ clipPath: CLIP, WebkitClipPath: CLIP }}
/>
</span>
</button>
</motion.div>
);
})}
</div>
))}
</div>
<p className="mt-16 text-center text-sm text-slate-500 dark:text-zinc-500">
{PHOTOS.length} plates · shot on assignment · arrow keys navigate the
lightbox
</p>
</div>
{/* Lightbox */}
<AnimatePresence>
{active != null && (
<motion.div
key="ghx-lightbox"
role="dialog"
aria-modal="true"
aria-label={`${PHOTOS[active].title}, image ${active + 1} of ${PHOTOS.length}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.22 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 p-4 backdrop-blur-md sm:p-8"
onClick={close}
>
<motion.figure
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.32, ease: [0.22, 1, 0.36, 1] }}
onClick={(e) => e.stopPropagation()}
className="relative flex max-h-full w-full max-w-3xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-zinc-900 shadow-2xl"
>
<div className="relative flex items-center justify-center bg-black">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={PHOTOS[active].src}
alt={PHOTOS[active].alt}
loading="lazy"
draggable={false}
className="max-h-[65vh] w-full object-contain"
/>
</div>
<figcaption className="flex items-start justify-between gap-4 px-6 py-5">
<div>
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-300">
{PHOTOS[active].category}
</span>
<h3 className="mt-1 text-lg font-semibold text-white">
{PHOTOS[active].title}
</h3>
<p className="mt-1.5 max-w-md text-sm leading-relaxed text-zinc-400">
{PHOTOS[active].caption}
</p>
</div>
<span className="shrink-0 rounded-full bg-white/10 px-3 py-1 text-xs font-medium tabular-nums text-zinc-300">
{active + 1} / {PHOTOS.length}
</span>
</figcaption>
{/* Controls */}
<button
type="button"
onClick={close}
aria-label="Close"
className="absolute right-3 top-3 grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white outline-none backdrop-blur transition hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-indigo-400"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
onClick={() => step(-1)}
aria-label="Previous image"
className="absolute left-3 top-[calc(32.5vh-1.25rem)] grid h-11 w-11 place-items-center rounded-full bg-white/10 text-white outline-none backdrop-blur transition hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-indigo-400"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
<path d="M15 6l-6 6 6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
onClick={() => step(1)}
aria-label="Next image"
className="absolute right-3 top-[calc(32.5vh-1.25rem)] grid h-11 w-11 place-items-center rounded-full bg-white/10 text-white outline-none backdrop-blur transition hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-indigo-400"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
<path d="M9 6l6 6-6 6" 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.

