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.
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
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.
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.
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.
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.
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.
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.