Product Grid with Filter Chips

A collection section for a small shop. A heading and short description sit on the left, and a row of filter chips with a count on each sits on the right. Below is a grid of eight square product tiles, each with a name, a type, a price and an add to bag button that switches to a confirmed state. Picking a chip filters the grid straight away. The look is deliberately sharp, with square corners and a warm stone palette. On phones the grid drops to two columns and the chips scroll sideways in one line.

Code

import { useState } from "react";

const categories = ["All", "Mugs", "Bowls", "Vases", "Plates"];

// `bg` is the tile colour and `glaze` the colour of the drawn piece. Swap the drawing for an <img> when you have photos.
const products = [
  { id: 1, name: "Speckled Mug", category: "Mugs", price: 28, note: "New", bg: "bg-stone-200", glaze: "text-stone-50" },
  { id: 2, name: "Ramen Bowl", category: "Bowls", price: 38, note: "", bg: "bg-orange-100", glaze: "text-orange-700" },
  { id: 3, name: "Shoulder Vase", category: "Vases", price: 58, note: "2 left", bg: "bg-teal-100", glaze: "text-teal-800" },
  { id: 4, name: "Dinner Plate", category: "Plates", price: 30, note: "", bg: "bg-stone-300", glaze: "text-stone-50" },
  { id: 5, name: "Tall Latte Mug", category: "Mugs", price: 32, note: "", bg: "bg-orange-100", glaze: "text-orange-800" },
  { id: 6, name: "Bud Vase", category: "Vases", price: 34, note: "New", bg: "bg-stone-200", glaze: "text-stone-700" },
  { id: 7, name: "Pinch Bowl Trio", category: "Bowls", price: 24, note: "", bg: "bg-teal-100", glaze: "text-teal-700" },
  { id: 8, name: "Side Plate", category: "Plates", price: 22, note: "", bg: "bg-amber-100", glaze: "text-amber-700" },
];

export default function ProductGridWithFilterChips() {
  const [filter, setFilter] = useState("All");
  const [bag, setBag] = useState<number[]>([]);
  const shown = products.filter((p) => filter === "All" || p.category === filter);
  const countFor = (c: string) => (c === "All" ? products.length : products.filter((p) => p.category === c).length);
  const focus = "focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2";

  return (
    <section className="bg-stone-50 px-4 py-14 text-stone-900 sm:px-6 sm:py-20">
      <div className="mx-auto max-w-6xl">
        <div className="flex flex-col gap-6 border-b border-stone-300 pb-6 lg:flex-row lg:items-end lg:justify-between">
          <div>
            <p className="text-xs font-semibold uppercase tracking-widest text-orange-800">Kilnside Ceramics</p>
            <h2 className="mt-2 text-3xl font-semibold tracking-tight sm:text-4xl">Autumn firing</h2>
            <p className="mt-2 max-w-md text-stone-600">Thrown and glazed by hand in small batches. When a piece sells out it is gone until the next firing.</p>
          </div>
          <div>
            <div role="group" aria-label="Filter by type" className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 sm:pb-0">
              {categories.map((c) => (
                <button
                  key={c}
                  type="button"
                  aria-pressed={filter === c}
                  onClick={() => setFilter(c)}
                  className={`flex h-10 flex-none items-center gap-2 border px-4 text-sm font-medium transition ${focus} ${filter === c ? "border-stone-900 bg-stone-900 text-white" : "border-stone-300 bg-white text-stone-700 hover:border-stone-900 hover:text-stone-900"}`}
                >
                  {c}
                  <span className={`text-xs tabular-nums ${filter === c ? "text-stone-300" : "text-stone-500"}`}>{countFor(c)}</span>
                </button>
              ))}
            </div>
            <p aria-live="polite" className="sr-only">
              Showing {shown.length} {shown.length === 1 ? "piece" : "pieces"}
            </p>
          </div>
        </div>

        <ul className="mt-8 grid grid-cols-2 gap-x-4 gap-y-8 sm:gap-x-6 sm:gap-y-10 lg:grid-cols-4">
          {shown.map((p) => {
            const inBag = bag.includes(p.id);
            return (
              <li key={p.id} className="flex flex-col">
                <div className={`relative flex aspect-square items-center justify-center ${p.bg}`}>
                  {p.note && <span className="absolute left-0 top-3 bg-white px-2.5 py-1 text-xs font-semibold text-stone-900">{p.note}</span>}
                  <svg viewBox="0 0 120 120" fill="currentColor" aria-hidden="true" className={`h-3/4 w-3/4 ${p.glaze}`}>
                    <ellipse cx="60" cy="100" rx="38" ry="5" fill="black" opacity=".1" />
                    {p.category === "Mugs" && (
                      <>
                        <path d="M76 50h7a11 11 0 0 1 0 22h-7" fill="none" stroke="currentColor" strokeWidth="6" />
                        <rect x="34" y="40" width="44" height="58" rx="7" />
                        <rect x="34" y="40" width="44" height="7" rx="3.5" fill="black" opacity=".08" />
                      </>
                    )}
                    {p.category === "Bowls" && (
                      <>
                        <rect x="49" y="88" width="22" height="10" rx="2" />
                        <path d="M20 54h80a40 40 0 0 1-80 0Z" />
                        <rect x="20" y="52" width="80" height="6" rx="3" fill="black" opacity=".08" />
                      </>
                    )}
                    {p.category === "Vases" && (
                      <>
                        <path d="M51 22h18v13c14 8 21 20 21 33 0 18-13 30-30 30S30 86 30 68c0-13 7-25 21-33V22Z" />
                        <rect x="49" y="20" width="22" height="6" rx="3" fill="black" opacity=".1" />
                      </>
                    )}
                    {p.category === "Plates" && (
                      <>
                        <circle cx="60" cy="58" r="42" />
                        <circle cx="60" cy="58" r="28" fill="black" opacity=".06" />
                      </>
                    )}
                  </svg>
                </div>
                <div className="mb-3 mt-3 flex items-start justify-between gap-3">
                  <div className="min-w-0">
                    <h3 className="text-sm font-semibold leading-snug">{p.name}</h3>
                    <p className="mt-0.5 text-sm text-stone-600">{p.category}</p>
                  </div>
                  <p className="flex-none text-sm font-semibold tabular-nums">${p.price}</p>
                </div>
                <button
                  type="button"
                  aria-pressed={inBag}
                  aria-label={inBag ? `Remove ${p.name} from bag` : `Add ${p.name} to bag`}
                  onClick={() => setBag((b) => (inBag ? b.filter((id) => id !== p.id) : [...b, p.id]))}
                  className={`mt-auto flex h-10 w-full items-center justify-center gap-2 border text-sm font-semibold transition ${focus} ${inBag ? "border-orange-800 bg-orange-800 text-white" : "border-stone-900 text-stone-900 hover:bg-stone-900 hover:text-white"}`}
                >
                  {inBag && (
                    <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4"><path d="m4.5 10.5 3.5 3.5 7.5-8" /></svg>
                  )}
                  {inBag ? "In your bag" : "Add to bag"}
                </button>
              </li>
            );
          })}
        </ul>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/product-grid-with-filter-chips.json

When to use it

How to customize it

Accessibility

Questions

How does the filtering work?

The chosen chip is stored in `useState`. The grid renders `products.filter(...)`, keeping only the items whose `category` matches, or everything when All is chosen. There is no request to a server.

Can shoppers pick more than one filter at a time?

Not as written. To allow it, store an array of chosen categories in place of a single string, toggle a category in and out on click, and keep a product when the array is empty or includes its category.

How do I keep the chosen filter in the page address?

Read the starting value from a query parameter such as `?type=mugs` and update the address with your router when a chip is pressed. That makes filtered views shareable and keeps the choice after a refresh.

What happens with many more products?

The grid handles a few dozen without changes. Beyond that, add pagination or a Load more button and consider filtering on the server so the page does not download every product.

Why do the chips scroll sideways on phones?

One scrolling row keeps the products near the top of the screen. If you would prefer the chips to wrap onto several lines, remove `overflow-x-auto` and add `flex-wrap`.

Related: Cart Drawer with Quantity Steppers, Stat Tiles with Sparkline Trends, Button Set: Primary, Secondary, Ghost, Icon and Loading, Split Hero with Email Capture.

More ecommerce or all free UI components. Start for free to have a full site built around it.

More in Ecommerce

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap