EmpireUI
Get Pro
← Blog8 min read#ecommerce#filters#ui design

E-Commerce Product Filters UI: Sidebar, Chips, Active State

A practical guide to building product filter UIs in React — sidebar panels, chip groups, active state management, and URL sync that won't make users ragequit.

E-commerce product filter sidebar with active chip selections on screen

Why Product Filters Break More Often Than They Should

Filters are the second thing users reach for on any product listing page — right after the search bar. Get them wrong and you're bleeding conversions. Shoppers will quietly bail instead of telling you the filter UX made them feel stupid.

Honestly, most filter UIs fail at exactly three things: the active state isn't obvious enough, removing a single filter requires too many clicks, and the sidebar collapses weirdly on mobile. Each of those is a solvable engineering problem, not a design mystery.

This article walks through building a sidebar filter panel with chip-based active selections, proper URL state sync, and accessible active states — all in React. We'll use real px values, look at where state should actually live, and skip the hand-wavy advice.

Worth noting: if you want polished component starting points before you write a single line of filter logic, Empire UI has several layout primitives and card shells that pair well with everything shown here.

Sidebar Filter Panel: Structure and Layout

The sidebar should never be an afterthought slapped on with position: fixed. In 2024, the dominant pattern is a sticky aside that scrolls independently while the product grid scrolls on the right. Set height: 100vh on the sidebar, give it overflow-y: auto, and pin it with position: sticky; top: 0. That 60px top offset? It's for your navbar.

Group filters into collapsible sections — <details> elements work fine here and are free accessibility wins. Each section handles one dimension: Category, Price Range, Size, Color, Rating. Keep each section's max-height around 220px with internal scroll so long lists (like a 40-item brand list) don't explode the sidebar height.

// SidebarFilter.tsx
import { useState } from 'react'

type FilterSection = {
  id: string
  label: string
  options: { value: string; label: string; count: number }[]
}

function FilterSection({ section, selected, onToggle }: {
  section: FilterSection
  selected: string[]
  onToggle: (value: string) => void
}) {
  const [open, setOpen] = useState(true)

  return (
    <div className="border-b border-zinc-200 py-4">
      <button
        className="flex w-full items-center justify-between text-sm font-semibold text-zinc-800"
        onClick={() => setOpen(!open)}
      >
        {section.label}
        <span>{open ? '−' : '+'}</span>
      </button>
      {open && (
        <ul className="mt-3 max-h-56 space-y-2 overflow-y-auto">
          {section.options.map(opt => (
            <li key={opt.value}>
              <label className="flex cursor-pointer items-center gap-2 text-sm text-zinc-600">
                <input
                  type="checkbox"
                  checked={selected.includes(opt.value)}
                  onChange={() => onToggle(opt.value)}
                  className="h-4 w-4 rounded border-zinc-300 accent-indigo-600"
                />
                {opt.label}
                <span className="ml-auto text-xs text-zinc-400">({opt.count})</span>
              </label>
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

One more thing — that accent-indigo-600 on the checkbox is doing real work. It matches the active chip color we'll set up in the next section, so the whole filter system feels visually coherent without you needing a custom checkbox component.

Quick aside: the count badge (opt.count) next to each option should update to reflect how many results exist given the *other* active filters. That's called faceted counting and it's backend work — but even showing static counts beats hiding them entirely.

Active Chips: Building the Selections Bar

Once a user picks a filter, you need to show it somewhere above the grid. Not buried in the sidebar. Active chips — small pill buttons showing the current selections — are the standard answer. They let users see exactly what's active and remove individual filters without opening the sidebar at all.

The chip bar lives between the filter sidebar and the product grid header. It renders only when activeFilters.length > 0, and it always includes a 'Clear all' link at the end. Keep chip height at 32px, use 8px horizontal padding, and give it a subtle filled background so it's obviously interactive and not just a tag label.

// ActiveFilterChips.tsx
type ActiveFilter = { key: string; value: string; label: string }

function ActiveFilterChips({
  filters,
  onRemove,
  onClearAll,
}: {
  filters: ActiveFilter[]
  onRemove: (key: string, value: string) => void
  onClearAll: () => void
}) {
  if (filters.length === 0) return null

  return (
    <div className="flex flex-wrap items-center gap-2 py-3">
      {filters.map(f => (
        <span
          key={`${f.key}-${f.value}`}
          className="inline-flex items-center gap-1.5 rounded-full bg-indigo-100 px-3 py-1 text-xs font-medium text-indigo-800"
        >
          {f.label}
          <button
            onClick={() => onRemove(f.key, f.value)}
            aria-label={`Remove ${f.label} filter`}
            className="ml-0.5 rounded-full text-indigo-500 hover:text-indigo-800"
          >
            ×
          </button>
        </span>
      ))}
      <button
        onClick={onClearAll}
        className="text-xs text-zinc-500 underline hover:text-zinc-800"
      >
        Clear all
      </button>
    </div>
  )
}

In practice, the trickiest part isn't rendering — it's the label. Your filter state might store { size: ['xl', '2xl'] } but your chip needs to display 'XL' and '2XL', not the raw values. Build a label map alongside your filter config and resolve it at render time, not at the point where state is set.

That said, don't over-engineer the chip display. Some teams try to group chips by category ('Size: XL, 2XL') to save space. That's fine at scale, but for most stores with under 6 active filters at a time, individual chips are clearer.

State Management: Where Filter State Actually Belongs

Here's the question nobody asks early enough: where does filter state live? Component state, React Context, Zustand, or the URL? The answer is almost always the URL. Always. Filters in the URL are shareable, bookmarkable, browser-history-aware, and they survive a full-page refresh without you writing a single line of rehydration code.

Use useSearchParams from React Router v6 (or Next.js's useSearchParams hook if you're on App Router). Represent multi-value filters as repeated params: ?color=red&color=blue&size=xl. That's the standard and it parses cleanly with searchParams.getAll('color').

// useFilterState.ts — Next.js App Router version
'use client'
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
import { useCallback } from 'react'

export function useFilterState() {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()

  const getFilter = (key: string) => searchParams.getAll(key)

  const toggleFilter = useCallback(
    (key: string, value: string) => {
      const current = new URLSearchParams(Array.from(searchParams.entries()))
      const existing = current.getAll(key)

      if (existing.includes(value)) {
        current.delete(key)
        existing.filter(v => v !== value).forEach(v => current.append(key, v))
      } else {
        current.append(key, value)
      }

      // Reset pagination when filters change
      current.delete('page')
      router.push(`${pathname}?${current.toString()}`, { scroll: false })
    },
    [searchParams, pathname, router]
  )

  const clearAll = useCallback(() => {
    router.push(pathname, { scroll: false })
  }, [pathname, router])

  return { getFilter, toggleFilter, clearAll }
}

Notice the scroll: false option. Without it, every filter click jumps the user back to the top of the page. That's disorienting when they're mid-grid. Disable scroll restoration for filter updates — but re-enable it for page-number changes where jumping to top actually makes sense.

One thing to reset on every filter change: page number. If a user is on page 4 and adds a filter, page 4 might not exist anymore. Always delete page from the params when any filter toggles. That's a bug that'll live in production for months if you don't handle it upfront.

Active State Styling: Making Selected Filters Obvious

Active state is where a lot of filter UIs fall flat. A checkbox that goes from border-zinc-300 to border-zinc-300 bg-indigo-600 is not obvious enough at a glance. Users scan fast. The state change needs to be unambiguous.

For checkboxes in the sidebar, rely on accent-color for the checkbox fill but also add a visual change to the parent <label>: a left border highlight or a subtle background shift on the row works well. 4px left border in indigo-500 at full opacity reads clearly even on a small sidebar.

/* Filter option row — active state */
.filter-option {
  border-left: 4px solid transparent;
  padding-left: 8px;
  transition: border-color 150ms ease;
}

.filter-option:has(input:checked) {
  border-left-color: #6366f1; /* indigo-500 */
  background-color: #eef2ff;  /* indigo-50 */
}

The :has() selector has been in all major browsers since 2023. Use it. It lets you style the parent label based on the child checkbox state without any JavaScript, which keeps your event handlers lean.

Look, if you want more visual personality in your filter UI — something beyond flat checkboxes — check out the glassmorphism components on Empire UI. A frosted-glass sidebar panel feels premium for fashion or lifestyle brands without adding any real complexity. The glassmorphism generator can get you the exact backdrop-filter values in about 30 seconds.

Mobile: Drawer vs. Bottom Sheet vs. Modal

On mobile, the sidebar pattern dies. You're not fitting a 280px sidebar next to a product grid on a 390px screen. You need a full-screen or overlay approach, and the three main options each have tradeoffs.

A drawer that slides in from the left mirrors the desktop sidebar mentally — users understand it's the same thing. A bottom sheet is more thumb-friendly on iOS, especially for stores where the user's other hand is holding their phone. A modal works but feels heavier and breaks the sense that you're still on the same page.

In practice, the bottom sheet wins for mobile-first stores. Use a fixed-height sheet with internal scroll — start it at 70% viewport height with a drag handle. Libraries like vaul give you a production-ready bottom sheet in about 10 lines. Don't build this from scratch in 2026.

// Mobile filter trigger
<button
  className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2 rounded-full bg-zinc-900 px-6 py-3 text-sm font-medium text-white shadow-lg lg:hidden"
  onClick={() => setDrawerOpen(true)}
>
  Filters {activeCount > 0 && `(${activeCount})`}
</button>

That floating button pattern — centered, sticky at the bottom — is what Zara and ASOS shipped in 2024 and it's now basically the convention. The active count in the button label is the key detail. Without it, users have no idea filters are active after they close the drawer.

Price Range Sliders and Range Inputs

Price range is the filter that gets botched the most. A dual-handle range slider looks great in Figma and becomes a nightmare when both thumbs are close together on mobile. The minimum viable approach: two number inputs with a simple visual range bar underneath. It's less flashy and infinitely more usable.

If you do go with a slider, set a minimum gap between the two handles — something like $10 — to prevent them from overlapping. Debounce the URL update: don't push a new route on every onChange. Wait 300ms after the user stops dragging before writing to the URL, or you'll hammer your API with dozens of requests per second.

// Debounced price range update
import { useDebouncedCallback } from 'use-debounce'

const updatePriceParams = useDebouncedCallback(
  (min: number, max: number) => {
    const params = new URLSearchParams(searchParams.toString())
    params.set('priceMin', String(min))
    params.set('priceMax', String(max))
    params.delete('page')
    router.push(`${pathname}?${params.toString()}`, { scroll: false })
  },
  300
)

Show the current price range as text above the slider: '$45 – $180'. Don't make users squint at the slider position to figure out their range. Text is free. Use it.

Worth noting: price filters benefit from preset quick-picks alongside the range input — 'Under $50', '$50–$100', 'Over $100'. These cover 80% of cases and are much faster than dragging a slider. You can combine both in the same section without cluttering the UI. Browse the box shadow generator if you want to give the range track a subtle depth effect that matches your store's visual style.

FAQ

Should filter state live in React state or in the URL?

Almost always the URL. URL-based filters are shareable, bookmarkable, and survive page refreshes without extra code. Use useSearchParams from Next.js or React Router and represent multi-select filters as repeated params like ?color=red&color=blue.

What's the right way to handle filter active state visually?

Don't rely on checkbox fill alone — it's too subtle. Add a 4px left border in your brand color and a light background tint to the selected row. The CSS :has(input:checked) selector handles this without any JavaScript and works in all major browsers since 2023.

How do I handle the mobile sidebar on small screens?

Ditch the sidebar entirely on mobile. Use a floating 'Filters' button that opens a bottom sheet — vaul is a good library for this. Show the active filter count in the button label so users know filters are applied after they close the sheet.

Why should I reset the page number when a filter changes?

If a user is on page 4 and adds a filter, page 4 might have zero results. Always delete the page param from the URL whenever any filter toggles. It's a one-liner and prevents a confusing empty state that's hard to debug in production.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

E-Commerce Product Card Design: 8 Layouts That Actually ConvertE-Commerce Checkout UI: Step-by-Step, Trust Signals, Error StatesProduct Filter Sidebar in React: Price Range, Multi-Select, URL StateE-Commerce Product Page in Tailwind: Gallery, Options, CTA