Tabs Collections Gallery
Original · freeA tabbed gallery that switches between named photo collections with an animated grid transition.
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-tabs-collections.json"use client";
import { useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Orientation = "portrait" | "landscape" | "square";
interface Photo {
src: string;
title: string;
place: string;
orientation: Orientation;
}
interface Collection {
id: string;
label: string;
blurb: string;
/** static Tailwind classes so the component stays portable */
dot: string;
ring: string;
chip: string;
photos: Photo[];
}
const aspectClass: Record<Orientation, string> = {
portrait: "aspect-[4/5]",
landscape: "aspect-[13/9]",
square: "aspect-square",
};
const collections: Collection[] = [
{
id: "coastal",
label: "Coastal",
blurb: "Salt air, long horizons and the slow patience of tides.",
dot: "bg-sky-500",
ring: "focus-visible:ring-sky-500/60",
chip: "text-sky-700 dark:text-sky-300",
photos: [
{ src: "/img/gallery/g02.webp", title: "Low tide, first light", place: "Coastal light", orientation: "landscape" },
{ src: "/img/gallery/g07.webp", title: "Harbour wall", place: "Northern shore", orientation: "portrait" },
{ src: "/img/gallery/g11.webp", title: "Drift", place: "Open water", orientation: "square" },
{ src: "/img/gallery/g20.webp", title: "The long pier", place: "Coastal light", orientation: "landscape" },
{ src: "/img/gallery/g26.webp", title: "Weathered hull", place: "Fishing village", orientation: "portrait" },
{ src: "/img/gallery/g33.webp", title: "Foam & stone", place: "Tidal pool", orientation: "square" },
],
},
{
id: "studio",
label: "Studio",
blurb: "Controlled light, quiet objects and deliberate composition.",
dot: "bg-rose-500",
ring: "focus-visible:ring-rose-500/60",
chip: "text-rose-700 dark:text-rose-300",
photos: [
{ src: "/img/gallery/g05.webp", title: "Studio 04", place: "Still life", orientation: "portrait" },
{ src: "/img/gallery/g16.webp", title: "Paper & shadow", place: "Studio 07", orientation: "landscape" },
{ src: "/img/gallery/g23.webp", title: "Ceramic study", place: "Studio 02", orientation: "square" },
{ src: "/img/gallery/g30.webp", title: "Portrait, softbox", place: "Studio 11", orientation: "portrait" },
{ src: "/img/gallery/g09.webp", title: "Fold", place: "Textile series", orientation: "landscape" },
{ src: "/img/gallery/g18.webp", title: "Single bloom", place: "Studio 03", orientation: "square" },
],
},
{
id: "nature",
label: "Nature",
blurb: "Field, forest and the fine grain of growing things.",
dot: "bg-emerald-500",
ring: "focus-visible:ring-emerald-500/60",
chip: "text-emerald-700 dark:text-emerald-300",
photos: [
{ src: "/img/gallery/g14.webp", title: "Understory", place: "Nature", orientation: "landscape" },
{ src: "/img/gallery/g21.webp", title: "Frost on fern", place: "Highland edge", orientation: "portrait" },
{ src: "/img/gallery/g27.webp", title: "Meadow, late June", place: "Nature", orientation: "square" },
{ src: "/img/gallery/g36.webp", title: "Canopy break", place: "Old woodland", orientation: "landscape" },
{ src: "/img/gallery/g12.webp", title: "Seed head", place: "Wild margin", orientation: "portrait" },
{ src: "/img/gallery/g29.webp", title: "River bend", place: "Valley floor", orientation: "square" },
],
},
{
id: "urban",
label: "Urban",
blurb: "Concrete geometry, glass reflections and city rhythm.",
dot: "bg-amber-500",
ring: "focus-visible:ring-amber-500/60",
chip: "text-amber-700 dark:text-amber-300",
photos: [
{ src: "/img/gallery/g03.webp", title: "Stairwell", place: "Downtown", orientation: "portrait" },
{ src: "/img/gallery/g08.webp", title: "Crossing", place: "Urban", orientation: "landscape" },
{ src: "/img/gallery/g15.webp", title: "Facade, noon", place: "Financial quarter", orientation: "square" },
{ src: "/img/gallery/g24.webp", title: "Underpass", place: "Urban", orientation: "landscape" },
{ src: "/img/gallery/g31.webp", title: "Reflected tower", place: "Riverside", orientation: "portrait" },
{ src: "/img/gallery/g19.webp", title: "Neon rain", place: "Old market", orientation: "square" },
],
},
];
export default function GalleryTabsCollections() {
const [active, setActive] = useState<number>(0);
const reduce = useReducedMotion();
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const current = collections[active];
const onTabKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, i: number) => {
const count = collections.length;
let next = i;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (i + 1) % count;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (i - 1 + count) % count;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = count - 1;
else return;
e.preventDefault();
setActive(next);
tabRefs.current[next]?.focus();
};
const container = {
hidden: { opacity: reduce ? 1 : 0 },
show: {
opacity: 1,
transition: reduce ? { duration: 0 } : { staggerChildren: 0.055, delayChildren: 0.04 },
},
exit: {
opacity: reduce ? 1 : 0,
transition: reduce ? { duration: 0 } : { duration: 0.18 },
},
} as const;
const item = {
hidden: reduce ? { opacity: 1 } : { opacity: 0, y: 26, filter: "blur(8px)" },
show: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: reduce ? { duration: 0 } : { duration: 0.55, ease: [0.22, 1, 0.36, 1] as const },
},
} as const;
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-100">
<style>{`
@keyframes gtc-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.55); opacity: .35; }
}
.gtc-pulse { animation: gtc-pulse 2.6s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.gtc-pulse { animation: none !important; }
}
`}</style>
{/* atmosphere */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-70 [background:radial-gradient(60rem_40rem_at_15%_-10%,theme(colors.sky.200/45),transparent),radial-gradient(50rem_35rem_at_100%_10%,theme(colors.rose.200/35),transparent)] dark:opacity-40 dark:[background:radial-gradient(60rem_40rem_at_15%_-10%,theme(colors.sky.500/18),transparent),radial-gradient(50rem_35rem_at_100%_10%,theme(colors.rose.500/14),transparent)]"
/>
<div className="relative mx-auto w-full max-w-6xl px-5 sm:px-8">
{/* header */}
<div className="max-w-2xl">
<span className="inline-flex items-center gap-2 text-xs font-medium uppercase tracking-[0.28em] text-neutral-500 dark:text-neutral-400">
<span className="h-px w-8 bg-neutral-400 dark:bg-neutral-600" />
Selected work
</span>
<h2 className="mt-5 font-serif text-4xl font-medium leading-[1.05] tracking-tight text-neutral-900 sm:text-5xl dark:text-neutral-50">
Collections
</h2>
<p className="mt-4 text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
A curated archive, grouped by mood and place. Switch between sets to see
each collection settle into its own grid.
</p>
</div>
{/* tabs */}
<div
role="tablist"
aria-label="Photo collections"
className="mt-10 flex flex-wrap gap-1.5 rounded-2xl border border-neutral-200/70 bg-white/60 p-1.5 shadow-sm backdrop-blur-sm sm:inline-flex dark:border-white/10 dark:bg-white/5"
>
{collections.map((c, i) => {
const selected = i === active;
return (
<button
key={c.id}
ref={(el) => {
tabRefs.current[i] = el;
}}
role="tab"
id={`gtc-tab-${c.id}`}
aria-selected={selected}
aria-controls="gtc-panel"
tabIndex={selected ? 0 : -1}
onClick={() => setActive(i)}
onKeyDown={(e) => onTabKeyDown(e, i)}
className={`relative isolate flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 ${c.ring} ${
selected
? "text-neutral-900 dark:text-white"
: "text-neutral-500 hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-100"
}`}
>
{selected && (
<motion.span
layoutId="gtc-pill"
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 34 }}
className="absolute inset-0 -z-10 rounded-xl bg-white shadow-sm ring-1 ring-neutral-200/80 dark:bg-neutral-800 dark:ring-white/10"
/>
)}
<span className="relative flex h-1.5 w-1.5 items-center justify-center">
<span className={`absolute inline-flex h-1.5 w-1.5 rounded-full ${c.dot} ${selected ? "gtc-pulse" : "opacity-50"}`} />
<span className={`inline-flex h-1.5 w-1.5 rounded-full ${c.dot}`} />
</span>
{c.label}
<span className="text-xs tabular-nums text-neutral-400 dark:text-neutral-500">{c.photos.length}</span>
</button>
);
})}
</div>
{/* active blurb */}
<AnimatePresence mode="wait">
<motion.p
key={current.id + "-blurb"}
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -6 }}
transition={{ duration: 0.25 }}
className="mt-6 max-w-xl text-sm italic leading-relaxed text-neutral-500 dark:text-neutral-400"
>
{current.blurb}
</motion.p>
</AnimatePresence>
{/* panel */}
<div id="gtc-panel" role="tabpanel" aria-labelledby={`gtc-tab-${current.id}`} tabIndex={0} className="mt-8 outline-none">
<AnimatePresence mode="wait">
<motion.div
key={current.id}
variants={container}
initial="hidden"
animate="show"
exit="exit"
className="gap-4 [column-fill:_balance] sm:columns-2 lg:columns-3"
>
{current.photos.map((p) => (
<motion.figure
key={p.src}
variants={item}
className="group relative mb-4 block break-inside-avoid overflow-hidden rounded-2xl bg-neutral-200 ring-1 ring-black/5 dark:bg-neutral-800 dark:ring-white/10"
>
<div className={`${aspectClass[p.orientation]} w-full overflow-hidden`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.src}
alt={`${p.title} — ${p.place}`}
loading="lazy"
draggable={false}
className="h-full w-full object-cover transition-transform duration-[900ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform group-hover:scale-[1.05]"
/>
</div>
{/* gradient + caption */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 translate-y-2 bg-gradient-to-t from-black/75 via-black/25 to-transparent p-4 pt-12 opacity-0 transition-all duration-500 group-hover:translate-y-0 group-hover:opacity-100">
<figcaption className="text-sm font-medium leading-tight text-white drop-shadow-sm">
{p.title}
</figcaption>
<span className={`mt-1 inline-block rounded-full bg-white/90 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide ${current.chip}`}>
{p.place}
</span>
</div>
</motion.figure>
))}
</motion.div>
</AnimatePresence>
</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 →
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.

