Diagonal Grid Gallery
Original · freeA skewed, diagonal image grid whose tiles straighten and zoom 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-diagonal-grid.json"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
AnimatePresence,
motion,
useInView,
useReducedMotion,
} from "motion/react";
type Orientation = "portrait" | "landscape" | "square";
type Tile = {
src: string;
title: string;
category: string;
caption: string;
orientation: Orientation;
};
const TILES: Tile[] = [
{
src: "/img/gallery/g03.webp",
title: "Atrium, First Light",
category: "Architecture",
caption: "6:12am — the east glazing catches the day before the street does.",
orientation: "portrait",
},
{
src: "/img/gallery/g11.webp",
title: "Salt Flats, Km 214",
category: "Landscape",
caption: "A horizon so flat the sky forgets where it started.",
orientation: "landscape",
},
{
src: "/img/gallery/g07.webp",
title: "Ceramicist's Hands",
category: "Craft",
caption: "Wet clay, forty years of muscle memory, one honest bowl.",
orientation: "square",
},
{
src: "/img/gallery/g19.webp",
title: "Stairwell No. 4",
category: "Architecture",
caption: "Concrete spiralling down into its own shadow.",
orientation: "portrait",
},
{
src: "/img/gallery/g24.webp",
title: "Harbour Fog",
category: "Landscape",
caption: "Cranes fading out one grey silhouette at a time.",
orientation: "landscape",
},
{
src: "/img/gallery/g15.webp",
title: "Market, 5th Aisle",
category: "Street",
caption: "Persimmons stacked like a small orange argument.",
orientation: "square",
},
{
src: "/img/gallery/g28.webp",
title: "Reeds at Dusk",
category: "Nature",
caption: "Wind writing the same sentence over and over.",
orientation: "portrait",
},
{
src: "/img/gallery/g31.webp",
title: "Bridge Underpass",
category: "Architecture",
caption: "Rivets, rust, and the low hum of traffic overhead.",
orientation: "landscape",
},
{
src: "/img/gallery/g22.webp",
title: "Studio Portrait, Ilse",
category: "Portrait",
caption: "One softbox, one window, and a face that needed neither.",
orientation: "square",
},
{
src: "/img/gallery/g09.webp",
title: "Dune Ridge",
category: "Landscape",
caption: "The desert keeps its edges sharp until the sun leaves.",
orientation: "portrait",
},
];
const SPAN: Record<Orientation, string> = {
portrait: "row-[span_5] sm:row-[span_5]",
landscape: "col-[span_2] row-[span_3]",
square: "row-[span_4]",
};
export default function GalleryDiagonalGrid() {
const sectionRef = useRef<HTMLDivElement>(null);
const inView = useInView(sectionRef, { once: true, margin: "-12% 0px" });
const reduce = useReducedMotion();
const prefix = useId().replace(/[:]/g, "");
const [open, setOpen] = useState<number | null>(null);
const closeBtnRef = useRef<HTMLButtonElement>(null);
const close = useCallback(() => setOpen(null), []);
const go = useCallback((dir: 1 | -1) => {
setOpen((cur) => {
if (cur === null) return cur;
return (cur + dir + TILES.length) % TILES.length;
});
}, []);
useEffect(() => {
if (open === null) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
else if (e.key === "ArrowRight") go(1);
else if (e.key === "ArrowLeft") go(-1);
};
window.addEventListener("keydown", onKey);
const t = window.setTimeout(() => closeBtnRef.current?.focus(), 0);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
window.clearTimeout(t);
document.body.style.overflow = prevOverflow;
};
}, [open, close, go]);
const active = open === null ? null : TILES[open];
return (
<section 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 sm:py-28 dark:from-zinc-950 dark:via-zinc-900 dark:to-black dark:text-zinc-50">
<style>{`
@keyframes ${prefix}-rise {
from { opacity: 0; transform: translateY(28px) rotate(-6deg) scale(0.94); }
to { opacity: 1; transform: translateY(0) rotate(-6deg) scale(1); }
}
@keyframes ${prefix}-sheen {
0% { transform: translateX(-120%) rotate(6deg); opacity: 0; }
18% { opacity: 0.55; }
60% { opacity: 0; }
100% { transform: translateX(240%) rotate(6deg); opacity: 0; }
}
.${prefix}-tile {
transition:
transform 620ms cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 620ms cubic-bezier(0.16, 1, 0.3, 1),
z-index 0ms;
will-change: transform;
}
.${prefix}-img {
transition: transform 800ms cubic-bezier(0.16, 1, 0.3, 1), filter 620ms ease;
}
.${prefix}-meta {
transition: opacity 500ms ease, transform 620ms cubic-bezier(0.16, 1, 0.3, 1);
}
.${prefix}-group:hover .${prefix}-tile,
.${prefix}-group:focus-within .${prefix}-tile {
transform: rotate(12deg) scale(1.06) !important;
z-index: 30;
box-shadow:
0 30px 60px -18px rgba(15, 23, 42, 0.45),
0 8px 20px -8px rgba(79, 70, 229, 0.35);
}
.${prefix}-group:hover .${prefix}-img,
.${prefix}-group:focus-within .${prefix}-img {
transform: scale(1.14);
filter: saturate(1.08) contrast(1.03);
}
.${prefix}-group:hover .${prefix}-meta,
.${prefix}-group:focus-within .${prefix}-meta {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.${prefix}-tile,
.${prefix}-img,
.${prefix}-meta { transition-duration: 1ms !important; }
.${prefix}-group:hover .${prefix}-tile,
.${prefix}-group:focus-within .${prefix}-tile {
transform: rotate(6deg) !important;
}
.${prefix}-sheen { display: none !important; }
[data-gdg-rise] { animation: none !important; opacity: 1 !important; }
}
`}</style>
{/* ambient diagonal glow */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 opacity-70"
>
<div className="absolute -left-24 top-10 h-80 w-80 -rotate-12 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
<div className="absolute -right-16 bottom-0 h-96 w-96 rotate-12 rounded-full bg-violet-300/30 blur-3xl dark:bg-violet-700/20" />
</div>
<div className="mx-auto max-w-6xl">
<header 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-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium tracking-wide 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" />
Field Journal · Vol. 07
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-5xl">
The Diagonal Series
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-zinc-400">
Ten frames pinned at an angle. Hover any tile and it snaps
upright, leans forward, and shows its story.
</p>
</div>
<p className="shrink-0 text-sm text-slate-500 dark:text-zinc-500">
{TILES.length} photographs · 2024–2026
</p>
</header>
{/* skewed stage — the grid is rotated as a whole; tiles counter-rotate on hover */}
<div className="relative -mx-2 overflow-visible px-2 py-6">
<div
ref={sectionRef}
className="grid auto-rows-[46px] grid-cols-2 gap-4 sm:auto-rows-[54px] sm:grid-cols-3 sm:gap-5 lg:grid-cols-4"
style={{ transform: "rotate(-6deg)", transformOrigin: "center" }}
>
{TILES.map((t, i) => {
const delay = 90 * i;
const isOpen = open === i;
return (
<div
key={t.src}
className={`${prefix}-group group relative ${SPAN[t.orientation]}`}
data-gdg-rise=""
style={{
animation:
inView && !reduce
? `${prefix}-rise 760ms cubic-bezier(0.16,1,0.3,1) ${delay}ms both`
: undefined,
opacity: inView || reduce ? undefined : 0,
transform: "rotate(-6deg)",
}}
>
<button
type="button"
onClick={() => setOpen(i)}
aria-label={`Open “${t.title}”, ${t.category}`}
aria-haspopup="dialog"
aria-expanded={isOpen}
className={`${prefix}-tile relative block h-full w-full overflow-hidden rounded-2xl bg-slate-200 text-left shadow-lg shadow-slate-900/10 outline-none ring-indigo-500 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-zinc-800 dark:shadow-black/40 dark:ring-offset-zinc-950`}
style={{ transform: "rotate(6deg) scale(1)" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={t.src}
alt={`${t.title} — ${t.caption}`}
loading="lazy"
draggable={false}
className={`${prefix}-img h-full w-full select-none object-cover`}
/>
{/* sheen swipe */}
<span
aria-hidden
className={`${prefix}-sheen pointer-events-none absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/70 to-transparent`}
style={{
animation: reduce
? undefined
: `${prefix}-sheen 2.8s ease-in-out ${1.2 + i * 0.15}s infinite`,
}}
/>
{/* gradient scrim + meta */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent opacity-90"
/>
<span
className={`${prefix}-meta pointer-events-none absolute inset-x-0 bottom-0 flex translate-y-2 flex-col gap-1 p-3 opacity-0 sm:p-4`}
>
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-indigo-300">
{t.category}
</span>
<span className="text-sm font-semibold leading-tight text-white sm:text-base">
{t.title}
</span>
</span>
</button>
</div>
);
})}
</div>
</div>
<p className="mt-14 text-center text-xs text-slate-400 dark:text-zinc-600">
Tip: use Tab to focus a frame, Enter to open. Arrow keys move between
photos in the viewer.
</p>
</div>
{/* Lightbox */}
<AnimatePresence>
{active && (
<motion.div
role="dialog"
aria-modal="true"
aria-label={`${active.title}, ${active.category}`}
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/90 p-4 backdrop-blur-md sm:p-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.24 }}
onClick={close}
>
<motion.figure
key={active.src}
className="relative flex max-h-full w-full max-w-4xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl dark:bg-zinc-900"
initial={{ scale: reduce ? 1 : 0.92, y: reduce ? 0 : 16, opacity: 0 }}
animate={{ scale: 1, y: 0, opacity: 1 }}
exit={{ scale: reduce ? 1 : 0.96, opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.3, ease: [0.16, 1, 0.3, 1] }}
onClick={(e) => e.stopPropagation()}
>
<div className="relative bg-slate-100 dark:bg-black">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.src}
alt={`${active.title} — ${active.caption}`}
loading="lazy"
draggable={false}
className="mx-auto max-h-[68vh] w-auto max-w-full select-none object-contain"
/>
<button
ref={closeBtnRef}
type="button"
onClick={close}
aria-label="Close viewer"
className="absolute right-3 top-3 grid h-10 w-10 place-items-center rounded-full bg-white/90 text-slate-700 shadow outline-none ring-indigo-500 transition hover:bg-white focus-visible:ring-2 dark:bg-zinc-800/90 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M6 6l12 12M18 6L6 18"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</button>
<button
type="button"
onClick={() => go(-1)}
aria-label="Previous photo"
className="absolute left-3 top-1/2 grid h-11 w-11 -translate-y-1/2 place-items-center rounded-full bg-white/90 text-slate-700 shadow outline-none ring-indigo-500 transition hover:bg-white focus-visible:ring-2 dark:bg-zinc-800/90 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M15 5l-7 7 7 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
onClick={() => go(1)}
aria-label="Next photo"
className="absolute right-3 top-1/2 grid h-11 w-11 -translate-y-1/2 place-items-center rounded-full bg-white/90 text-slate-700 shadow outline-none ring-indigo-500 transition hover:bg-white focus-visible:ring-2 dark:bg-zinc-800/90 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M9 5l7 7-7 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
<figcaption className="flex items-start justify-between gap-4 border-t border-slate-200 p-5 dark:border-zinc-800">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
{active.category}
</p>
<h3 className="mt-1 text-lg font-semibold text-slate-900 dark:text-zinc-50">
{active.title}
</h3>
<p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-zinc-400">
{active.caption}
</p>
</div>
<span className="shrink-0 rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-500 tabular-nums dark:bg-zinc-800 dark:text-zinc-400">
{String((open ?? 0) + 1).padStart(2, "0")} / {TILES.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.

