Flip Grid Gallery
Original · freeA grid of image cards that flip in 3D to a coloured caption back 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-flip-grid.json"use client";
import { useState } from "react";
import { motion } from "motion/react";
type Aspect = "portrait" | "landscape" | "square";
interface Shot {
src: string;
alt: string;
index: string;
category: string;
title: string;
caption: string;
location: string;
aspect: Aspect;
/** Full Tailwind class strings — never build these dynamically. */
backGradient: string;
badgeBg: string;
accentText: string;
}
const SHOTS: Shot[] = [
{
src: "/img/gallery/g03.webp",
alt: "Fog rolling between steel suspension cables at first light",
index: "01",
category: "Structure",
title: "Cable Study, 06:14",
caption:
"Shot from the north pylon before the crews arrived. The fog only held for four minutes.",
location: "Golden Gate, San Francisco",
aspect: "portrait",
backGradient: "bg-gradient-to-br from-indigo-500 to-violet-600",
badgeBg: "bg-indigo-400/25",
accentText: "text-indigo-100",
},
{
src: "/img/gallery/g07.webp",
alt: "Neon signage reflected in a rain-soaked alley at night",
index: "02",
category: "Night",
title: "Wet Neon",
caption:
"It rained for twenty minutes and turned the whole side street into a mirror.",
location: "Shinjuku, Tokyo",
aspect: "landscape",
backGradient: "bg-gradient-to-br from-rose-500 to-amber-500",
badgeBg: "bg-rose-400/25",
accentText: "text-rose-50",
},
{
src: "/img/gallery/g11.webp",
alt: "Sand dunes carved into sharp ridgelines by afternoon wind",
index: "03",
category: "Landscape",
title: "Ridgeline",
caption:
"The wind redrew this ridge between two exposures. No two frames matched.",
location: "Erg Chebbi, Morocco",
aspect: "square",
backGradient: "bg-gradient-to-br from-amber-400 to-rose-500",
badgeBg: "bg-amber-300/30",
accentText: "text-amber-50",
},
{
src: "/img/gallery/g14.webp",
alt: "A lone swimmer cutting across a still emerald harbour",
index: "04",
category: "Water",
title: "First Lap",
caption:
"One swimmer, the whole cove to herself, thirty seconds before the tour boats.",
location: "Cala Macarella, Menorca",
aspect: "portrait",
backGradient: "bg-gradient-to-br from-emerald-500 to-emerald-700",
badgeBg: "bg-emerald-300/25",
accentText: "text-emerald-50",
},
{
src: "/img/gallery/g19.webp",
alt: "Terraced vineyards stepping down toward a hazy valley floor",
index: "05",
category: "Landscape",
title: "Terraces",
caption:
"Every wall here was stacked by hand. You can read a century of harvests in the steps.",
location: "Douro Valley, Portugal",
aspect: "landscape",
backGradient: "bg-gradient-to-br from-violet-500 to-indigo-600",
badgeBg: "bg-violet-400/25",
accentText: "text-violet-100",
},
{
src: "/img/gallery/g22.webp",
alt: "A market vendor weighing spices under a striped awning",
index: "06",
category: "People",
title: "The Weigh-In",
caption:
"He let me watch for an hour before he let me shoot. Worth every minute of it.",
location: "Marrakech Medina",
aspect: "square",
backGradient: "bg-gradient-to-br from-amber-400 to-amber-600",
badgeBg: "bg-amber-300/30",
accentText: "text-amber-50",
},
{
src: "/img/gallery/g26.webp",
alt: "Brutalist concrete stairwell spiralling into deep shadow",
index: "07",
category: "Architecture",
title: "Descent",
caption:
"Handheld at 1/15th, braced against the rail. Concrete forgives a slow shutter.",
location: "Barbican, London",
aspect: "portrait",
backGradient: "bg-gradient-to-br from-slate-600 to-indigo-700",
badgeBg: "bg-slate-300/20",
accentText: "text-slate-100",
},
{
src: "/img/gallery/g29.webp",
alt: "Pastel row houses catching the last warm light of the day",
index: "08",
category: "Street",
title: "Golden Hour, Nyhavn",
caption:
"The canal empties for maybe ten minutes at dusk. This was minute nine.",
location: "Nyhavn, Copenhagen",
aspect: "landscape",
backGradient: "bg-gradient-to-br from-rose-400 to-violet-600",
badgeBg: "bg-rose-300/25",
accentText: "text-rose-50",
},
{
src: "/img/gallery/g31.webp",
alt: "Frost-covered pine forest under a pale winter sky",
index: "09",
category: "Winter",
title: "Silent Stand",
caption:
"Minus nineteen. The camera battery lasted four frames, this being the last.",
location: "Lapland, Finland",
aspect: "square",
backGradient: "bg-gradient-to-br from-indigo-400 to-emerald-600",
badgeBg: "bg-indigo-300/25",
accentText: "text-indigo-50",
},
{
src: "/img/gallery/g34.webp",
alt: "A cyclist crossing an empty plaza beneath long morning shadows",
index: "10",
category: "Street",
title: "Solo Commute",
caption:
"I waited three lights for a frame with exactly one person in it.",
location: "Plaza Mayor, Madrid",
aspect: "portrait",
backGradient: "bg-gradient-to-br from-emerald-500 to-indigo-600",
badgeBg: "bg-emerald-300/25",
accentText: "text-emerald-50",
},
];
const ASPECT_CLASS: Record<Aspect, string> = {
portrait: "aspect-[4/5]",
landscape: "aspect-[13/9]",
square: "aspect-square",
};
function FlipCard({ shot }: { shot: Shot }) {
const [flipped, setFlipped] = useState(false);
return (
<motion.figure
initial={{ opacity: 0, y: 26 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
className="mb-4 break-inside-avoid sm:mb-5"
>
<button
type="button"
onClick={() => setFlipped((v) => !v)}
aria-pressed={flipped}
aria-label={`${shot.title}. ${shot.category}, ${shot.location}. Activate to reveal the caption.`}
className={`gfg-card group relative block w-full rounded-2xl outline-none [perspective:1400px] focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950 ${
flipped ? "gfg-flipped" : ""
}`}
>
<div
className={`gfg-inner relative w-full [transform-style:preserve-3d] ${ASPECT_CLASS[shot.aspect]}`}
>
{/* FRONT */}
<div className="absolute inset-0 overflow-hidden rounded-2xl bg-zinc-200 shadow-sm ring-1 ring-zinc-900/10 [backface-visibility:hidden] dark:bg-zinc-800 dark:ring-white/10">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={shot.src}
alt={shot.alt}
loading="lazy"
draggable={false}
className="h-full w-full object-cover transition-transform duration-[900ms] ease-out group-hover:scale-[1.04]"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-zinc-950/55 via-zinc-950/0 to-zinc-950/0" />
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 p-3.5">
<span className="rounded-full bg-white/85 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-zinc-800 backdrop-blur-sm dark:bg-zinc-900/80 dark:text-zinc-100">
{shot.category}
</span>
<span className="gfg-hint inline-flex h-8 w-8 items-center justify-center rounded-full bg-white/85 text-zinc-800 backdrop-blur-sm dark:bg-zinc-900/80 dark:text-zinc-100">
<svg
viewBox="0 0 24 24"
fill="none"
className="h-4 w-4"
aria-hidden="true"
>
<path
d="M3 12a9 9 0 1 0 3-6.7M3 4v4h4"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</div>
</div>
{/* BACK */}
<div
className={`absolute inset-0 flex flex-col justify-between overflow-hidden rounded-2xl p-5 text-white shadow-lg [backface-visibility:hidden] [transform:rotateY(180deg)] ${shot.backGradient}`}
>
<div className="gfg-sheen pointer-events-none absolute inset-0 opacity-70" />
<div className="relative flex items-center justify-between">
<span
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider ${shot.badgeBg} ${shot.accentText}`}
>
{shot.category}
</span>
<span className={`font-mono text-xs ${shot.accentText}`}>
No. {shot.index}
</span>
</div>
<div className="relative">
<h3 className="text-lg font-semibold leading-tight tracking-tight text-white sm:text-xl">
{shot.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-white/90">
{shot.caption}
</p>
<p
className={`mt-4 flex items-center gap-1.5 text-xs font-medium ${shot.accentText}`}
>
<svg
viewBox="0 0 24 24"
fill="none"
className="h-3.5 w-3.5"
aria-hidden="true"
>
<path
d="M12 21s-7-5.686-7-11a7 7 0 1 1 14 0c0 5.314-7 11-7 11Z"
stroke="currentColor"
strokeWidth="1.7"
/>
<circle
cx="12"
cy="10"
r="2.4"
stroke="currentColor"
strokeWidth="1.7"
/>
</svg>
{shot.location}
</p>
</div>
</div>
</div>
</button>
</motion.figure>
);
}
export default function GalleryFlipGrid() {
return (
<section className="relative w-full overflow-hidden bg-zinc-50 px-5 py-20 sm:px-8 sm:py-28 dark:bg-zinc-950">
<style>{`
.gfg-inner {
transition: transform 620ms cubic-bezier(0.22, 1, 0.36, 1);
transform: rotateY(0deg);
}
.gfg-card:hover .gfg-inner,
.gfg-card:focus-visible .gfg-inner,
.gfg-card.gfg-flipped .gfg-inner {
transform: rotateY(180deg);
}
.gfg-hint {
animation: gfg-bob 2.8s ease-in-out infinite;
}
@keyframes gfg-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
.gfg-sheen {
background: linear-gradient(
115deg,
rgba(255,255,255,0) 30%,
rgba(255,255,255,0.22) 48%,
rgba(255,255,255,0) 66%
);
background-size: 260% 260%;
animation: gfg-sheen 5.5s ease-in-out infinite;
}
@keyframes gfg-sheen {
0% { background-position: 130% 0; }
100% { background-position: -130% 0; }
}
@media (prefers-reduced-motion: reduce) {
.gfg-inner { transition-duration: 1ms; }
.gfg-hint, .gfg-sheen { animation: none; }
}
`}</style>
{/* ambient background wash */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10"
>
<div className="absolute -left-24 top-0 h-72 w-72 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/15" />
<div className="absolute -right-24 bottom-0 h-80 w-80 rounded-full bg-rose-300/30 blur-3xl dark:bg-violet-700/15" />
</div>
<div className="mx-auto max-w-6xl">
<header className="mx-auto mb-12 max-w-2xl text-center sm:mb-16">
<span className="inline-flex items-center gap-2 rounded-full border border-zinc-900/10 bg-white/70 px-3 py-1 text-xs font-semibold uppercase tracking-widest text-zinc-600 backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-zinc-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Field Notes · 2024–25
</span>
<h2 className="mt-5 text-3xl font-semibold tracking-tight text-zinc-900 sm:text-4xl md:text-5xl dark:text-white">
Ten frames, ten stories
</h2>
<p className="mt-4 text-base leading-relaxed text-zinc-600 sm:text-lg dark:text-zinc-400">
A travelling photo journal. Hover, tap, or focus any frame to flip it
and read exactly where and when it was caught.
</p>
</header>
<div className="columns-1 gap-4 sm:columns-2 sm:gap-5 lg:columns-3">
{SHOTS.map((shot) => (
<FlipCard key={shot.src} shot={shot} />
))}
</div>
<p className="mt-10 text-center text-sm text-zinc-500 dark:text-zinc-500">
Every frame flips — give one a tap.
</p>
</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 →
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.

