Guides · September 3, 2023
Faceted Filters & Sort UIs with shadcn
Build a production-grade faceted filter and sort UI with shadcn/ui: URL-driven state, accessible checkboxes and comboboxes, server-friendly data shapes, and the patterns that keep large catalogs fast.
By Polo Themes
A good faceted filter UI in shadcn/ui comes down to three decisions: keep filter state in the URL (not just component state), compute facet counts on the server against the full result set, and build the controls from shadcn's Checkbox, Command, Popover, and Badge primitives rather than a single monolithic "filters" component. Get those three right and everything else — accessibility, back-button behavior, shareable links, sort UI — falls into place. This guide walks through the architecture and the code, from a single checkbox facet up through a combined filter-and-sort bar you can drop into a Next.js App Router storefront or dashboard.
Faceted search is one of the more deceptively hard pieces of UI in commerce and data-heavy apps. It looks like a list of checkboxes, but it's actually a state-synchronization problem: the URL, the server query, the facet counts, and the rendered controls all need to agree, and most implementations get one of those four out of sync. shadcn/ui doesn't ship a "filters" component because it's not a component — it's a pattern built from five or six primitives. This post is the pattern, written out in full, with the parts people usually get wrong called out explicitly.
Why Faceted Filters Are Harder Than They Look
Before writing any JSX, it's worth being precise about what a facet actually is. A facet is a dimension of your dataset (color, category, price range, brand) along with the *count of matching items for each value, given the current state of every other filter*. That last clause is the part that trips people up: facet counts are not static — they change as other filters are applied. If a shopper filters to "In Stock", the counts next to each color swatch should reflect only in-stock products, not the full catalog. A UI that shows stale counts looks broken even when the underlying filtering logic is correct, because users notice when they select a filter and the numbers next to the other options don't move.
The second thing that's harder than it looks is state ownership. Filter state has to survive a page refresh, a shared link, a back-button press, and (ideally) a full page load with JavaScript disabled or not yet hydrated. Component state (useState) fails all four. The only state container that survives all four is the URL's search params. This is the single most important architectural decision in this entire guide, so it gets its own section before any component code.
Decision One: URL Search Params Are the Source of Truth
Every filter selection, every sort choice, and the current page number should live in the URL as search params — something like ?color=black,blue&in_stock=true&sort=price_asc&page=2. The checkboxes and the sort dropdown are *views* onto that URL state, not independent state of their own. This gives you, for free: shareable and bookmarkable filtered views, correct back/forward button behavior, server-renderable initial state (no flash of unfiltered content), and a natural place to run analytics on which filter combinations people actually use.
In the Next.js App Router, this maps directly onto searchParams in a Server Component page and useRouter / useSearchParams in the Client Component controls. The pattern is: read filters from searchParams on the server, fetch the filtered data and facet counts server-side, and pass both down to client components that render controls and push new search params on change — never fetching client-side on every checkbox click if you can help it. A small helper for building the next URL keeps every control consistent:
- A shared useFilterParams hook that reads the current search params and exposes typed getters (getArray("color"), getString("sort")) plus a single setParam(key, value) / toggleArrayParam(key, value) function that calls router.push with the updated query string.
- Toggling a checkbox never mutates local component state directly — it calls toggleArrayParam("color", "black"), which reads the current URL, adds or removes the value, and pushes the new URL. The checkbox's checked state is derived from the URL on every render.
- Use router.push(url, { scroll: false }) so applying a filter doesn't yank the user back to the top of a long results page.
Decision Two: Facet Counts Come From the Server, Against the Whole Filter Set Minus One
The correct facet-count query for a given dimension excludes that dimension's own filter but applies every other active filter. Concretely: to compute the counts next to the color swatches, you run a GROUP BY color query filtered by the current price range, category, and in-stock flag — but *not* by the current color selection. If you applied the color filter too, every unselected color would show a count of zero the moment you picked one color, which is the classic bug that makes faceted filters feel broken.
In SQL this typically means one aggregate query per facet dimension (or a single query with multiple FILTER clauses / conditional aggregation if your database and ORM support it), each excluding its own dimension's predicate. If you're behind a headless commerce API rather than raw SQL, check whether it exposes a faceted-search or aggregations endpoint (most Medusa, Shopify Storefront API via a search integration, Algolia, Typesense, and Elasticsearch/OpenSearch setups do) — reimplementing facet aggregation client-side against a full unpaginated result set does not scale past a small catalog.
Building the Checkbox Facet
Start with shadcn's Checkbox and Label primitives (npx shadcn@latest add checkbox label). A single facet group is a small, self-contained client component: it receives the facet's options and counts as props (already computed server-side) and reads/writes its own slice of the URL.
- Render each option as a Checkbox + Label pair inside a <div className="flex items-center gap-2">, with the count rendered as muted text or a small Badge at the end of the row — text-muted-foreground text-xs reads well without competing with the label.
- Disable (don't hide) options whose count is 0 for the *current* filter combination — hiding options causes the filter panel to jump around as selections change, which is disorienting; a disabled, grayed-out row with "(0)" communicates the same thing without layout shift.
- Cap visible options at a reasonable number (8-10) with a "Show more" toggle rather than an infinite scrolling checkbox list — long facet lists are one of the more common accessibility and scanability failures in filter UIs.
- Wire onCheckedChange to your toggleArrayParam helper, not to local state — the checkbox's own checked prop should be computed from the URL (selectedColors.includes(option.value)) so it never drifts out of sync with what's actually being applied.
The Combobox Facet: When a Dimension Has Too Many Values
Checkboxes work well up to roughly a dozen values. Beyond that — brand names, tags, a "material" facet with forty entries — a searchable combobox is the better control. shadcn's Command component (built on cmdk) combined with Popover gives you a searchable, keyboard-navigable multi-select: npx shadcn@latest add command popover.
The structure is a Popover trigger (a button showing the count of selected values, e.g. "Brand (3)") that opens a Command list with a CommandInput for typing to filter, CommandGroup for the option list, and a CommandItem per value with a checkmark icon toggled by inclusion in the current selection. Selected values render as removable Badge chips below the trigger or inline in a filter summary row, each with an "x" that calls the same toggleArrayParam removal path as unchecking would. This is the same interaction pattern used for multi-select tag pickers generally, and it composes cleanly with the URL-as-state-container approach above — the combobox doesn't need any state of its own beyond the search input's transient text.
The "Active Filters" Summary Row
Once a filter panel has more than two or three facets, add a horizontal row of Badge components above the results grid, one per active filter value, each with a small "x" to remove it, plus a "Clear all" text button at the end. This single addition does more for perceived usability than almost any other change — it gives users a way to see everything they've applied at a glance and undo a specific choice without hunting through a sidebar of expanded/collapsed accordion sections to find where they set it. Build it as a small component that simply reads all array-valued search params and flat-maps them into badges; because state lives in the URL, this component needs no props beyond the params themselves.
Building the Sort UI
Sort is a single-value URL param (?sort=price_asc), and it deserves a Select (dropdown) rather than a DropdownMenu with custom items — Select carries the correct native-select semantics and keyboard behavior for a small, fixed list of mutually exclusive choices, which is exactly what sort options are. Install it with npx shadcn@latest add select if it isn't already in your project.
- Keep the option set short and meaningful: relevance/best-match (only when a search query is active), price ascending, price descending, newest, and — for catalogs with review data — highest rated. More than five or six sort options is rarely useful and mostly adds decision fatigue.
- Default to "Relevance" when there's an active search term and "Featured" or "Newest" otherwise — an unlabeled default sort silently applied by the database's natural row order looks arbitrary to users and is a common source of "why do these results seem random" bug reports.
- On change, call the same URL-update helper as the filters, and also reset page back to 1 — changing sort order while staying on page 3 of the old order produces a results page that doesn't correspond to any consistent ordering.
- Pair the Select with a compact results-count string ("128 results") to its left, so sort and total count read as one control cluster rather than two disconnected pieces of UI.
Layout: Sidebar vs. Toolbar vs. Sheet
Three layouts cover the vast majority of real catalogs. A persistent sidebar (facets in a left column, results in the main area) works well above roughly 1024px viewport width when you have three or more facet groups — it's the classic pattern and the one most shoppers already have a mental model for from large retail sites. A horizontal toolbar of Popover-based facet buttons (each opening its own checkbox or combobox panel) works better for one or two facets, or in dashboards and admin tables where vertical space is at a premium. On mobile, both collapse into a shadcn Sheet (npx shadcn@latest add sheet) triggered by a "Filters" button with an active-count badge — building three separate layouts is unnecessary; render the same facet components inside whichever container fits the viewport, using Tailwind's responsive classes to swap between an inline sidebar and a Sheet trigger rather than maintaining two component trees.
Performance and Data-Fetching Notes
A few practical notes that matter once a catalog grows past a demo-sized dataset. Debounce any facet that includes a text search input (the combobox's CommandInput) before it triggers a server request — 200-300ms is a reasonable default. Prefer server-side pagination with a stable sort tiebreaker (id or created_at as a secondary sort key) over offset pagination once a result set exceeds a few thousand rows, since offset pagination degrades and can produce duplicate or skipped rows if the underlying data changes between page loads; a keyset/cursor approach holds up better. And cache facet-count queries at whatever layer your stack supports (React Server Component request memoization, a CDN edge cache keyed on the query string, or a short-TTL Redis cache in front of the aggregation query) — facet counts change far less often than people expect, and recomputing them from scratch on every keystroke of an unrelated filter is one of the more common unnecessary-load sources in these UIs.
If you're assembling a storefront and want a starting visual system for the surrounding product grid, cards, and buy box rather than building every screen from a blank canvas, our Figma UI kits are a reasonable reference point for the layout conventions this kind of filter-and-sort bar needs to sit inside — they're design assets rather than code, but the spacing and hierarchy decisions translate directly into the component structure described above.
Accessibility Checklist
- Every Checkbox has an associated Label with a matching htmlFor/id pair — clicking the text should toggle the box, not just clicking the small square.
- The facet panel's heading is a real heading element (or has an aria-level if visually restyled), so screen reader users can jump between facet groups.
- Command already handles arrow-key navigation and Enter-to-select; don't override its keydown handling unless you have a specific reason to.
- Announce result-count changes to assistive technology with an aria-live="polite" region near the results-count text, so a screen reader user who applies a filter hears "42 results" without needing to re-navigate to find the count.
- Give the mobile filter Sheet trigger button an accessible label that includes the active filter count ("Filters, 3 active"), not just the word "Filters".
Frequently Asked Questions
Does shadcn/ui have a built-in faceted filter component?
No — shadcn/ui is intentionally a set of primitives (Checkbox, Command, Popover, Select, Badge, Sheet) rather than a prebuilt "filters" widget. A faceted filter UI is assembled from those primitives plus your own state-management pattern, which is exactly what this guide covers.
Should filter state live in React state, Zustand, or the URL?
The URL. Component or store state doesn't survive a refresh, a shared link, or the back button, and all three of those are normal user behavior around filtered lists. Read filters from URL search params on the server, and have every control push updated search params rather than holding its own copy of the selection.
How do I keep facet counts accurate as filters change?
Compute each facet's counts with every *other* active filter applied, but excluding that facet's own predicate. This requires one aggregate query per dimension (or conditional aggregation in a single query) computed server-side against the current filter state, not a client-side count over an already-paginated result set.
Checkboxes or a combobox for a given facet?
Checkboxes for roughly a dozen values or fewer, where scanning the full list is fast. A searchable Command+Popover combobox once a dimension has many values (brands, tags), since typing to narrow the list beats scrolling through dozens of checkboxes.
Does this pattern work with a headless commerce backend rather than a custom database?
Yes — the component and URL-state layer described here is backend-agnostic. What changes is where facet counts come from: check whether your commerce platform or search layer (Medusa's query module, a dedicated search service like Algolia or Typesense, or Elasticsearch/OpenSearch) exposes a faceted-search or aggregations endpoint, and use that instead of hand-rolling aggregation queries.