Pills Filter Gallery
Original · freeA gallery filtered by clickable pill tags with an animated grid.
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-pills-filter.json"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
type Category =
| "Architecture"
| "Nature"
| "Interiors"
| "Portraits"
| "Travel"
| "Urban";
type Orientation = "portrait" | "landscape" | "square";
type Shot = {
src: string;
title: string;
place: string;
category: Category;
orientation: Orientation;
};
const SHOTS: Shot[] = [
{
src: "/img/gallery/g01.webp",
title: "Morning Light on Steel",
place: "Rotterdam, NL",
category: "Architecture",
orientation: "portrait",
},
{
src: "/img/gallery/g08.webp",
title: "Alpine Ridge Line",
place: "Grisons, CH",
category: "Nature",
orientation: "landscape",
},
{
src: "/img/gallery/g03.webp",
title: "The Quiet Reading Room",
place: "Copenhagen, DK",
category: "Interiors",
orientation: "square",
},
{
src: "/img/gallery/g04.webp",
title: "Vendor at Rua Augusta",
place: "Lisbon, PT",
category: "Portraits",
orientation: "portrait",
},
{
src: "/img/gallery/g11.webp",
title: "Harbor at Blue Hour",
place: "Bergen, NO",
category: "Urban",
orientation: "landscape",
},
{
src: "/img/gallery/g12.webp",
title: "Old Town Crossroads",
place: "Kotor, ME",
category: "Travel",
orientation: "square",
},
{
src: "/img/gallery/g07.webp",
title: "Concrete Spiral",
place: "São Paulo, BR",
category: "Architecture",
orientation: "portrait",
},
{
src: "/img/gallery/g02.webp",
title: "Coastal Fog at Dawn",
place: "Big Sur, US",
category: "Nature",
orientation: "landscape",
},
{
src: "/img/gallery/g09.webp",
title: "Studio in the Corner",
place: "Kyoto, JP",
category: "Interiors",
orientation: "square",
},
{
src: "/img/gallery/g10.webp",
title: "The Fishmonger's Hands",
place: "Marrakesh, MA",
category: "Portraits",
orientation: "portrait",
},
{
src: "/img/gallery/g06.webp",
title: "Neon After Rain",
place: "Osaka, JP",
category: "Urban",
orientation: "landscape",
},
{
src: "/img/gallery/g05.webp",
title: "Desert Highway 91",
place: "Wadi Rum, JO",
category: "Travel",
orientation: "square",
},
];
const CATEGORIES: Category[] = [
"Architecture",
"Nature",
"Interiors",
"Portraits",
"Travel",
"Urban",
];
type Filter = "All" | Category;
const FILTERS: Filter[] = ["All", ...CATEGORIES];
const COUNTS: Record<Filter, number> = FILTERS.reduce((acc, f) => {
acc[f] = f === "All" ? SHOTS.length : SHOTS.filter((s) => s.category === f).length;
return acc;
}, {} as Record<Filter, number>);
const SPAN: Record<Orientation, string> = {
landscape: "sm:col-span-2 sm:row-span-2",
portrait: "row-span-3",
square: "row-span-2",
};
const spring = { type: "spring", stiffness: 260, damping: 30, mass: 0.9 } as const;
export default function GalleryPillsFilter() {
const [active, setActive] = useState<Filter>("All");
const [lightbox, setLightbox] = useState<number | null>(null);
const visible = useMemo(
() => (active === "All" ? SHOTS : SHOTS.filter((s) => s.category === active)),
[active],
);
const closeLightbox = useCallback(() => setLightbox(null), []);
const step = useCallback(
(dir: 1 | -1) => {
setLightbox((i) => {
if (i === null) return i;
return (i + dir + visible.length) % visible.length;
});
},
[visible.length],
);
useEffect(() => {
if (lightbox === null) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeLightbox();
if (e.key === "ArrowRight") step(1);
if (e.key === "ArrowLeft") step(-1);
};
window.addEventListener("keydown", onKey);
document.documentElement.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.documentElement.style.overflow = "";
};
}, [lightbox, closeLightbox, step]);
const current = lightbox !== null ? visible[lightbox] : null;
return (
<section className="relative w-full overflow-hidden bg-stone-50 px-5 py-20 text-stone-900 sm:px-8 sm:py-28 dark:bg-neutral-950 dark:text-stone-100">
<style>{gpfKeyframes}</style>
{/* atmosphere */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.5] [background-image:radial-gradient(circle_at_15%_0%,rgba(217,119,6,0.12),transparent_45%),radial-gradient(circle_at_90%_10%,rgba(79,70,229,0.12),transparent_40%)] dark:opacity-[0.7]"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-stone-300 to-transparent dark:via-white/15"
/>
<div className="relative mx-auto max-w-6xl">
{/* header */}
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-xl">
<span className="inline-flex items-center gap-2 text-[0.7rem] font-medium uppercase tracking-[0.25em] text-amber-700 dark:text-amber-400">
<span className="gpf-pulse h-1.5 w-1.5 rounded-full bg-amber-600 dark:bg-amber-400" />
Field Notes / 2026
</span>
<h2 className="mt-4 text-4xl font-semibold leading-[1.05] tracking-tight text-balance sm:text-5xl">
A traveling archive of{" "}
<span className="italic text-amber-700 dark:text-amber-300">light</span> and
place.
</h2>
<p className="mt-4 text-base leading-relaxed text-stone-600 dark:text-stone-400">
Twelve frames from eleven cities. Tap a tag to sift the collection —
the grid rearranges itself around whatever you are looking for.
</p>
</div>
<div className="shrink-0 text-right">
<div className="text-5xl font-semibold tabular-nums tracking-tight sm:text-6xl">
{String(visible.length).padStart(2, "0")}
</div>
<div className="text-xs uppercase tracking-[0.2em] text-stone-500 dark:text-stone-500">
frames shown
</div>
</div>
</div>
{/* pills */}
<div
role="tablist"
aria-label="Filter photographs by subject"
className="mt-10 flex flex-wrap gap-2.5"
>
{FILTERS.map((f) => {
const isActive = active === f;
return (
<button
key={f}
role="tab"
aria-selected={isActive}
onClick={() => setActive(f)}
className={[
"group relative inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium",
"outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-50 dark:focus-visible:ring-offset-neutral-950",
isActive
? "text-white dark:text-neutral-950"
: "text-stone-700 hover:text-stone-950 dark:text-stone-300 dark:hover:text-white",
].join(" ")}
>
{isActive && (
<motion.span
layoutId="gpf-pill"
transition={spring}
className="absolute inset-0 -z-10 rounded-full bg-stone-900 shadow-lg shadow-stone-900/20 dark:bg-amber-300 dark:shadow-amber-300/20"
/>
)}
{!isActive && (
<span className="absolute inset-0 -z-10 rounded-full border border-stone-300 bg-white/60 backdrop-blur-sm transition-colors group-hover:border-stone-400 dark:border-white/10 dark:bg-white/[0.04] dark:group-hover:border-white/25" />
)}
{f}
<span
className={[
"rounded-full px-1.5 py-0.5 text-[0.65rem] tabular-nums transition-colors",
isActive
? "bg-white/20 text-white dark:bg-neutral-950/15 dark:text-neutral-950"
: "bg-stone-200/80 text-stone-600 dark:bg-white/10 dark:text-stone-400",
].join(" ")}
>
{COUNTS[f]}
</span>
</button>
);
})}
</div>
{/* grid */}
<motion.div
layout
className="mt-8 grid auto-rows-[70px] grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4"
>
<AnimatePresence mode="popLayout">
{visible.map((shot, i) => (
<motion.figure
key={shot.src}
layout
initial={{ opacity: 0, scale: 0.92, filter: "blur(6px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.92, filter: "blur(6px)" }}
transition={spring}
className={[
"group relative overflow-hidden rounded-2xl",
"bg-stone-200 ring-1 ring-stone-900/5 dark:bg-neutral-900 dark:ring-white/10",
SPAN[shot.orientation],
].join(" ")}
>
<button
type="button"
onClick={() => setLightbox(i)}
aria-label={`Open ${shot.title}, ${shot.place}`}
className="absolute inset-0 z-20 h-full w-full cursor-zoom-in outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-amber-400"
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={shot.src}
alt={`${shot.title} — ${shot.category.toLowerCase()} photograph made in ${shot.place}`}
loading="lazy"
draggable={false}
className="h-full w-full object-cover transition-transform duration-[600ms] ease-out will-change-transform group-hover:scale-[1.05]"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/5 to-transparent opacity-90 transition-opacity duration-300 sm:opacity-0 sm:group-hover:opacity-100" />
<figcaption className="pointer-events-none absolute inset-x-0 bottom-0 z-10 translate-y-1 p-3 opacity-100 transition-all duration-300 sm:translate-y-2 sm:opacity-0 sm:group-hover:translate-y-0 sm:group-hover:opacity-100">
<span className="inline-block rounded-full bg-white/15 px-2 py-0.5 text-[0.6rem] font-medium uppercase tracking-[0.15em] text-white/90 backdrop-blur-sm">
{shot.category}
</span>
<h3 className="mt-1.5 text-sm font-semibold leading-tight text-white text-balance">
{shot.title}
</h3>
<p className="text-[0.7rem] text-white/70">{shot.place}</p>
</figcaption>
</motion.figure>
))}
</AnimatePresence>
</motion.div>
{visible.length === 0 && (
<p className="mt-10 text-center text-sm text-stone-500">
No frames in this collection yet.
</p>
)}
</div>
{/* lightbox */}
<AnimatePresence>
{current && (
<motion.div
role="dialog"
aria-modal="true"
aria-label={`${current.title}, ${current.place}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={closeLightbox}
className="fixed inset-0 z-50 flex items-center justify-center bg-stone-950/80 p-4 backdrop-blur-md sm:p-8"
>
<button
type="button"
onClick={closeLightbox}
aria-label="Close viewer"
className="absolute right-4 top-4 z-10 rounded-full border border-white/20 bg-white/10 p-2 text-white outline-none transition-colors hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-amber-400 sm:right-6 sm:top-6"
>
<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={(e) => { e.stopPropagation(); step(-1); }}
aria-label="Previous photograph"
className="absolute left-3 z-10 rounded-full border border-white/20 bg-white/10 p-2.5 text-white outline-none transition-colors hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-amber-400 sm:left-6"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
<path d="M15 5l-7 7 7 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); step(1); }}
aria-label="Next photograph"
className="absolute right-3 z-10 rounded-full border border-white/20 bg-white/10 p-2.5 text-white outline-none transition-colors hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-amber-400 sm:right-6"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
<path d="M9 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<motion.figure
key={current.src}
initial={{ opacity: 0, scale: 0.96, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={spring}
onClick={(e) => e.stopPropagation()}
className="relative flex max-h-full max-w-3xl flex-col overflow-hidden rounded-2xl bg-neutral-900 shadow-2xl ring-1 ring-white/10"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={current.src}
alt={`${current.title} — ${current.category.toLowerCase()} photograph made in ${current.place}`}
loading="lazy"
draggable={false}
className="max-h-[70vh] w-auto object-contain"
/>
<figcaption className="flex items-center justify-between gap-4 border-t border-white/10 bg-neutral-900 px-5 py-4">
<div>
<h3 className="text-base font-semibold text-white text-balance">
{current.title}
</h3>
<p className="text-sm text-stone-400">{current.place}</p>
</div>
<span className="shrink-0 rounded-full border border-amber-400/40 px-3 py-1 text-xs font-medium uppercase tracking-[0.15em] text-amber-300">
{current.category}
</span>
</figcaption>
</motion.figure>
</motion.div>
)}
</AnimatePresence>
</section>
);
}
const gpfKeyframes = `
@keyframes gpf-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.6); opacity: 0.45; }
}
.gpf-pulse { animation: gpf-pulse 2.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.gpf-pulse { animation: none; }
}
`;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.

