# Blog Category Filter — React Blog Section

> Filter chips with a sliding active pill and live counts; the post grid reflows with shared layout animation as cards pop in and out.

- Category: [Blog Sections](https://pixelpapa.com/components/blog.md)
- Tags: blog, filter, chips, layout, grid
- Dependencies: motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/blog/blog-category-filter

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/blog-category-filter.json"
```

The file is written to `components/pixelpapa/blog-category-filter.tsx`. To install manually, run `npm install motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { useState } from "react";
import { cn } from "@/lib/utils";

type Post = { title: string; category: string; date: string; minutes: number };

const defaultPosts: Post[] = [
  { title: "Rate limiting without the angry emails", category: "Engineering", date: "Nov 9", minutes: 8 },
  { title: "Our onboarding checklist lost three steps", category: "Product", date: "Nov 2", minutes: 4 },
  { title: "A colour system that survives dark mode", category: "Design", date: "Oct 27", minutes: 7 },
  { title: "Hiring our first support engineer", category: "Company", date: "Oct 21", minutes: 5 },
  { title: "Idempotency keys, explained with pizza", category: "Engineering", date: "Oct 14", minutes: 9 },
  { title: "Why every table needs an empty state", category: "Design", date: "Oct 6", minutes: 6 },
  { title: "Usage-based billing: one year in", category: "Product", date: "Sep 29", minutes: 10 },
  { title: "Tracing a request across eleven services", category: "Engineering", date: "Sep 18", minutes: 12 },
];

const tones: Record<string, [string, string]> = {
  Engineering: ["var(--chart-1)", "var(--chart-2)"],
  Product: ["var(--chart-5)", "var(--chart-2)"],
  Design: ["var(--chart-3)", "var(--chart-4)"],
  Company: ["var(--chart-4)", "var(--chart-1)"],
};

/** Deterministic little pattern per post so covers differ without assets. */
function Pattern({ seed, category }: { seed: number; category: string }) {
  const [a, b] = tones[category] ?? tones.Engineering;
  return (
    <div className="relative aspect-[16/9] overflow-hidden rounded-xl" style={{ background: `linear-gradient(135deg, ${a}, ${b})` }}>
      <svg viewBox="0 0 160 90" className="absolute inset-0 size-full transition-transform duration-700 group-hover:scale-110" aria-hidden>
        {Array.from({ length: 7 }, (_, i) => {
          const cx = ((seed * 37 + i * 53) % 150) + 5;
          const cy = ((seed * 19 + i * 29) % 80) + 5;
          const r = ((seed + i * 5) % 4) * 5 + 6;
          return i % 2 ? (
            <circle key={i} cx={cx} cy={cy} r={r} fill="white" fillOpacity={0.14 + (i % 3) * 0.1} />
          ) : (
            <rect key={i} x={cx - r} y={cy - r} width={r * 2} height={r * 2} rx={r / 2.5} fill="none" stroke="white" strokeOpacity="0.5" transform={`rotate(${seed * 11 + i * 17} ${cx} ${cy})`} />
          );
        })}
      </svg>
    </div>
  );
}

export default function BlogCategoryFilter({ posts = defaultPosts }: { posts?: Post[] }) {
  const categories = ["All", ...Array.from(new Set(posts.map((p) => p.category)))];
  const [filter, setFilter] = useState("All");
  const visible = posts.filter((p) => filter === "All" || p.category === filter);

  return (
    <section className="w-full bg-background px-6 py-20">
      <div className="mx-auto max-w-6xl">
        <div className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
          <div>
            <h2 className="text-4xl font-semibold tracking-tight text-foreground sm:text-5xl">The Switchboard blog</h2>
            <p className="mt-3 text-muted-foreground">Notes from the team building payments infrastructure for marketplaces.</p>
          </div>
          <div role="tablist" aria-label="Filter posts by category" className="flex flex-wrap gap-1 rounded-full border border-border bg-card p-1">
            {categories.map((c) => {
              const count = c === "All" ? posts.length : posts.filter((p) => p.category === c).length;
              const on = filter === c;
              return (
                <button
                  key={c}
                  type="button"
                  role="tab"
                  aria-selected={on}
                  onClick={() => setFilter(c)}
                  className={cn(
                    "relative flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-colors",
                    on ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground",
                  )}
                >
                  {on && (
                    <motion.span
                      layoutId="blog-category-filter-pill"
                      className="absolute inset-0 rounded-full bg-primary shadow-md shadow-primary/30"
                      transition={{ type: "spring", stiffness: 400, damping: 32 }}
                    />
                  )}
                  <span className="relative">{c}</span>
                  <span className={cn("relative rounded-full px-1.5 text-[11px] tabular-nums", on ? "bg-primary-foreground/20" : "bg-muted")}>{count}</span>
                </button>
              );
            })}
          </div>
        </div>

        <LayoutGroup id="blog-category-filter-grid">
          <motion.ul layout className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
            <AnimatePresence mode="popLayout" initial={false}>
              {visible.map((post) => (
                <motion.li
                  key={post.title}
                  layout
                  initial={{ opacity: 0, scale: 0.85 }}
                  animate={{ opacity: 1, scale: 1 }}
                  exit={{ opacity: 0, scale: 0.85 }}
                  transition={{ type: "spring", stiffness: 320, damping: 30 }}
                >
                  <a href="#" className="group block rounded-2xl border border-border bg-card p-3 transition-shadow duration-300 hover:shadow-xl hover:shadow-primary/10">
                    <Pattern seed={posts.indexOf(post) + 3} category={post.category} />
                    <div className="px-1.5 pt-4 pb-2">
                      <p className="flex items-center gap-2 text-xs text-muted-foreground">
                        <span className="size-1.5 rounded-full" style={{ background: (tones[post.category] ?? tones.Engineering)[0] }} />
                        {post.category} · {post.date} · {post.minutes} min
                      </p>
                      <h3 className="mt-2 leading-snug font-semibold tracking-tight text-card-foreground transition-colors group-hover:text-primary">
                        {post.title}
                      </h3>
                    </div>
                  </a>
                </motion.li>
              ))}
            </AnimatePresence>
          </motion.ul>
        </LayoutGroup>
      </div>
    </section>
  );
}
```

## AI prompt

```text
Add the "Blog Category Filter" component from PixelPapa UI to this project.

What it is: Filter chips with a sliding active pill and live counts; the post grid reflows with shared layout animation as cards pop in and out.
Category: Blog Sections. Depends on: motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/blog-category-filter.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/blog-category-filter.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/blog-category-filter" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More blog sections

- [Blog Changelog Feed](https://pixelpapa.com/components/blog/blog-changelog-feed.md) (Pro): A versioned changelog with a sticky date rail, colour-coded tags you can filter by, and entries that collapse out of the timeline with layout animation.
- [Blog Featured Plus Grid](https://pixelpapa.com/components/blog/blog-featured-plus-grid.md) (Pro): One large featured story beside its drawn cover, followed by a three-up grid; covers zoom and a reading arrow slides in on hover.
- [Blog List Hover Preview](https://pixelpapa.com/components/blog/blog-list-hover-preview.md): A typographic list of posts; a floating cover card trails the cursor on a spring, tilts with its speed and flips to the hovered post's artwork.
- [Blog Magazine Layout](https://pixelpapa.com/components/blog/blog-magazine-layout.md): A newspaper-style front page: masthead, a serif lead story with drawn cover art, a column of hairline-ruled briefs and a numbered most-read list.
