Web InnoventixFreeCode

Pills Filter Gallery

Original · free

A gallery filtered by clickable pill tags with an animated grid.

byWeb InnoventixReact + Tailwind
pillsfiltergalleryimages
Open

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
gallery-pills-filter.tsx
"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

motion

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 quote

Similar components

Browse all →
Hover Caption Gallery

Hover Caption Gallery

Original

A responsive image grid where each tile subtly zooms and reveals a caption bar on hover.

Masonry Gallery

Masonry Gallery

Original

A Pinterest-style masonry gallery using CSS columns with varied-height images; tiles lift and brighten on hover.

Filterable Gallery

Filterable Gallery

Original

A filterable gallery with category tabs; clicking a tab animates the grid to show only that category.

Tilt Cards Gallery

Tilt Cards Gallery

Original

A grid of image cards that tilt in 3D toward the cursor on hover, with a soft glare.

Lightbox Gallery

Lightbox Gallery

Original

A thumbnail grid where clicking a thumb opens a full-screen lightbox with prev/next arrows and keyboard support.

Expanding Strip Gallery

Expanding Strip Gallery

Original

A horizontal strip of image panels; hovering a panel expands it and shrinks the others to reveal its caption.

Marquee Gallery

Marquee Gallery

Original

Two rows of images auto-scrolling in opposite directions, pausing on hover, with edge fade masks.

Hover Zoom Overlay Gallery

Hover Zoom Overlay Gallery

Original

A grid where each image scales up under a dark gradient overlay revealing a title and a view button on hover.

Polaroid Stack Gallery

Polaroid Stack Gallery

Original

A scattered stack of slightly-rotated polaroid photos that straighten and lift on hover.

Featured Thumbs Gallery

Featured Thumbs Gallery

Original

A large featured image with a thumbnail row; selecting a thumbnail swaps the featured image with a crossfade.

Carousel Gallery

Carousel Gallery

Original

An image carousel with arrows and dots, smooth slide transitions and autoplay that pauses on hover.

Duotone Hover Gallery

Duotone Hover Gallery

Original

A grid where images sit under a coloured duotone wash that clears to full colour on hover.