Filterable Gallery
Original · freeA filterable gallery with category tabs; clicking a tab animates the grid to show only that category.
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-filterable.json"use client";
import { useMemo, useState } from "react";
import { motion, AnimatePresence, LayoutGroup } from "motion/react";
type Category = "Nature" | "Urban" | "People" | "Studio";
type Filter = "All" | Category;
interface Shot {
src: string;
title: string;
place: string;
category: Category;
span: "tall" | "wide" | "square";
}
const SHOTS: Shot[] = [
{ src: "/img/gallery/g03.webp", title: "Coastal light", place: "Amalfi, IT", category: "Nature", span: "tall" },
{ src: "/img/gallery/g11.webp", title: "Crossing hour", place: "Shibuya, JP", category: "Urban", span: "wide" },
{ src: "/img/gallery/g07.webp", title: "Studio 04", place: "In-house set", category: "Studio", span: "square" },
{ src: "/img/gallery/g19.webp", title: "Quiet regard", place: "Lisbon, PT", category: "People", span: "tall" },
{ src: "/img/gallery/g22.webp", title: "Pine ridge", place: "Dolomites, IT", category: "Nature", span: "wide" },
{ src: "/img/gallery/g14.webp", title: "Concrete grid", place: "Rotterdam, NL", category: "Urban", span: "square" },
{ src: "/img/gallery/g28.webp", title: "Warm profile", place: "Marrakech, MA", category: "People", span: "square" },
{ src: "/img/gallery/g31.webp", title: "Paper & light", place: "Product bench", category: "Studio", span: "tall" },
{ src: "/img/gallery/g05.webp", title: "Low tide", place: "Cornwall, UK", category: "Nature", span: "wide" },
{ src: "/img/gallery/g24.webp", title: "Night arcade", place: "Osaka, JP", category: "Urban", span: "tall" },
{ src: "/img/gallery/g17.webp", title: "Backstage two", place: "Editorial shoot", category: "People", span: "wide" },
{ src: "/img/gallery/g33.webp", title: "Matte still", place: "Tabletop rig", category: "Studio", span: "square" },
];
const FILTERS: Filter[] = ["All", "Nature", "Urban", "People", "Studio"];
const SPAN_CLASS: Record<Shot["span"], string> = {
tall: "row-span-2 aspect-[3/4]",
wide: "sm:col-span-2 aspect-[16/10]",
square: "aspect-square",
};
export default function GalleryFilterable() {
const [active, setActive] = useState<Filter>("All");
const counts = useMemo(() => {
const map: Record<Filter, number> = { All: SHOTS.length, Nature: 0, Urban: 0, People: 0, Studio: 0 };
for (const s of SHOTS) map[s.category] += 1;
return map;
}, []);
const visible = useMemo(
() => (active === "All" ? SHOTS : SHOTS.filter((s) => s.category === active)),
[active],
);
return (
<section className="relative w-full overflow-hidden bg-neutral-50 px-5 py-20 sm:px-8 sm:py-28 dark:bg-neutral-950">
<style>{`
@keyframes gfil-rise {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
.gfil-head { animation: gfil-rise .7s cubic-bezier(.22,1,.36,1) both; }
@media (prefers-reduced-motion: reduce) {
.gfil-head { animation: none; }
}
`}</style>
{/* ambient wash */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(60%_50%_at_50%_-10%,rgba(99,102,241,0.10),transparent_70%)] dark:bg-[radial-gradient(60%_50%_at_50%_-10%,rgba(129,140,248,0.14),transparent_70%)]"
/>
<div className="mx-auto max-w-6xl">
<header className="gfil-head mx-auto max-w-2xl text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-indigo-600 backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/70 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Selected work
</span>
<h2 className="mt-5 text-4xl font-semibold tracking-tight text-neutral-900 sm:text-5xl dark:text-neutral-50">
A field notebook of light
</h2>
<p className="mt-4 text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
Twelve frames across four disciplines. Filter the set and watch the grid
re-settle around what you want to see.
</p>
</header>
{/* filter tabs */}
<div
role="tablist"
aria-label="Filter photographs by category"
className="mx-auto mt-10 flex flex-wrap items-center justify-center gap-2"
>
<LayoutGroup id="gfil-tabs">
{FILTERS.map((f) => {
const selected = f === active;
return (
<button
key={f}
role="tab"
aria-selected={selected}
onClick={() => setActive(f)}
className={`relative rounded-full px-4 py-2 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950 ${
selected
? "text-white dark:text-neutral-900"
: "text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100"
}`}
>
{selected && (
<motion.span
layoutId="gfil-pill"
transition={{ type: "spring", stiffness: 420, damping: 34 }}
className="absolute inset-0 -z-10 rounded-full bg-neutral-900 dark:bg-neutral-100"
/>
)}
<span className="relative z-10">{f}</span>
<span
className={`relative z-10 ml-1.5 text-xs tabular-nums ${
selected
? "text-white/60 dark:text-neutral-900/50"
: "text-neutral-400 dark:text-neutral-600"
}`}
>
{counts[f]}
</span>
</button>
);
})}
</LayoutGroup>
</div>
{/* grid */}
<motion.div
layout
className="mt-12 grid auto-rows-[minmax(0,1fr)] grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4"
>
<AnimatePresence mode="popLayout">
{visible.map((shot) => (
<motion.figure
key={shot.src}
layout
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={{ type: "spring", stiffness: 320, damping: 30, mass: 0.6 }}
className={`group relative overflow-hidden rounded-2xl bg-neutral-200 ring-1 ring-neutral-900/5 dark:bg-neutral-800 dark:ring-white/10 ${SPAN_CLASS[shot.span]}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={shot.src}
alt={`${shot.title} — ${shot.category.toLowerCase()} photograph shot in ${shot.place}`}
loading="lazy"
draggable={false}
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-[1.06]"
/>
<div
aria-hidden
className="absolute inset-0 bg-gradient-to-t from-neutral-950/75 via-neutral-950/10 to-transparent opacity-80 transition-opacity duration-500 group-hover:opacity-95"
/>
<figcaption className="absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 p-4">
<div className="translate-y-1 transition-transform duration-500 group-hover:translate-y-0">
<p className="text-sm font-semibold text-white drop-shadow-sm">
{shot.title}
</p>
<p className="mt-0.5 text-xs text-white/70">{shot.place}</p>
</div>
<span className="shrink-0 rounded-full bg-white/15 px-2.5 py-1 text-[10px] font-medium uppercase tracking-wider text-white/90 backdrop-blur-md ring-1 ring-white/20">
{shot.category}
</span>
</figcaption>
</motion.figure>
))}
</AnimatePresence>
</motion.div>
<p className="mt-8 text-center text-sm text-neutral-500 dark:text-neutral-500">
Showing{" "}
<span className="font-medium text-neutral-700 tabular-nums dark:text-neutral-300">
{visible.length}
</span>{" "}
of {SHOTS.length} frames
{active !== "All" && (
<>
{" "}in{" "}
<span className="font-medium text-indigo-600 dark:text-indigo-400">
{active}
</span>
</>
)}
.
</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.

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.

Flip Cards Gallery
OriginalA grid of cards that flip in 3D on hover to reveal a caption and details on the back.

