Web InnoventixFreeCode

Filterable Gallery

Original · free

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

byWeb InnoventixReact + Tailwind
filterablegalleryimages
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-filterable.json
gallery-filterable.tsx
"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

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.

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.

Flip Cards Gallery

Flip Cards Gallery

Original

A grid of cards that flip in 3D on hover to reveal a caption and details on the back.