Collection Page Components Advanced 15 min read

Filtering UI: Sidebar and Active Filters

Build a filter sidebar with collapsible groups, price range sliders, and active filter chips. Handle URL state synchronization for shareable filtered views.

Filtering is critical for helping customers find products. In this lesson, we’ll build a complete filtering system with a sidebar, active filter chips, and URL synchronization for shareable links.

Theme Integration

This component is part of the collection page component hierarchy:

sections/collection-products.liquid
└── <div id="collection-products-root">
└── CollectionPage (React)
├── ActiveFilters ← You are here
└── FilterSidebar ← You are here

See Collection Page Architecture for the complete Liquid section setup and filter data serialization from collection.filters.

Data Source

Prop/StateSourceLiquid Field
filtersZustand store (from JSON)collection.filters
activeFiltersParsed from URLURL params + collection.filters
productsZustand storecollection.products (refetched on filter)
isLoadingLocal state-

Data Flow: Filtering

1. INITIAL LOAD (Server Render)
┌─────────────────────────────────────────────────────────┐
│ sections/collection-products.liquid │
│ └── collection.filters → JSON script tag │
│ └── collection.products → JSON script tag │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ useCollection store initializes with Liquid data │
│ └── parseActiveFiltersFromUrl() reads URL params │
└─────────────────────────────────────────────────────────┘
2. USER CLICKS FILTER
┌─────────────────────────────────────────────────────────┐
│ FilterSidebar → toggleFilter(filterId, value) │
│ └── Updates URL: ?filter.v.option.color=Blue │
│ └── Updates activeFilters state │
└─────────────────────────────────────────────────────────┘
3. FETCH NEW PRODUCTS
┌─────────────────────────────────────────────────────────┐
│ URL change triggers: fetch(newUrl) │
│ └── GET /collections/{handle}?filter... │
│ └── Parse HTML response for product data │
└─────────────────────────────────────────────────────────┘
4. UI UPDATE
┌─────────────────────────────────────────────────────────┐
│ products state updates → ProductGrid re-renders │
│ └── ActiveFilters shows current selections │
│ └── FilterSidebar shows updated counts │
└─────────────────────────────────────────────────────────┘

Shopify Storefront Filtering

Shopify provides filtering through URL parameters:

/collections/all?filter.v.option.color=Blue&filter.v.option.size=Medium&filter.p.price.gte=20&filter.p.price.lte=100

The collection.filters object gives us available filters with their values and counts.

Filter Types

Shopify supports several filter types:

TypeURL PatternExample
List (options)filter.v.option.{name}filter.v.option.color=Blue
Booleanfilter.p.m.{metafield}filter.p.m.custom.sale=true
Price rangefilter.p.price.gte/ltefilter.p.price.gte=50
Availabilityfilter.v.availabilityfilter.v.availability=1
Product typefilter.p.product_typefilter.p.product_type=Shirt
Vendorfilter.p.vendorfilter.p.vendor=Nike

Filter Sidebar Component

src/components/collection/FilterSidebar/FilterSidebar.tsx
import { useCollection } from '@/stores/collection';
import { FilterGroup } from './FilterGroup';
import { PriceRangeFilter } from './PriceRangeFilter';
import { Button } from '@/components/ui';
import styles from './FilterSidebar.module.css';
/**
* FilterSidebar renders all available collection filters.
* Includes a header with "Clear all" action when filters are active.
*/
export function FilterSidebar() {
// Get filters from store - these come from Shopify's collection.filters object.
const filters = useCollection((state) => state.filters);
const activeFilters = useCollection((state) => state.activeFilters);
const clearAllFilters = useCollection((state) => state.clearAllFilters);
// Check if any filters are currently applied.
const hasActiveFilters = Object.keys(activeFilters).length > 0;
return (
<aside className={styles.sidebar}>
{/* Header with title and optional clear button */}
<div className={styles.header}>
<h2 className={styles.title}>Filters</h2>
{/* Only show "Clear all" when filters are active */}
{hasActiveFilters && (
<button
type="button"
className={styles.clearAll}
onClick={clearAllFilters}
>
Clear all
</button>
)}
</div>
{/* Render each filter group - different component for price range */}
<div className={styles.groups}>
{filters.map((filter) => {
// Price range filters need a specialized slider component.
if (filter.type === 'price_range') {
return <PriceRangeFilter key={filter.id} filter={filter} />;
}
// Standard list/checkbox filters use FilterGroup component.
return (
<FilterGroup
key={filter.id}
filter={filter}
activeValues={activeFilters[filter.id] || []} // Pass current selections.
/>
);
})}
</div>
</aside>
);
}
src/components/collection/FilterSidebar/FilterSidebar.module.css
.sidebar {
position: sticky;
top: calc(var(--header-height, 60px) + 1rem);
max-height: calc(100vh - var(--header-height, 60px) - 2rem);
overflow-y: auto;
padding-right: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.title {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.clearAll {
padding: 0;
border: none;
background: none;
font-size: 0.875rem;
color: var(--color-text-muted);
text-decoration: underline;
cursor: pointer;
}
.clearAll:hover {
color: var(--color-text);
}
.groups {
display: flex;
flex-direction: column;
gap: 0.5rem;
}

Filter Group Component

Collapsible accordion for each filter:

src/components/collection/FilterSidebar/FilterGroup.tsx
import { useState } from 'react';
import { useCollection } from '@/stores/collection';
import type { Filter } from '@/types/collection';
import styles from './FilterGroup.module.css';
interface FilterGroupProps {
filter: Filter; // The filter object from Shopify (label, values, etc.).
activeValues: string[]; // Currently selected values for this filter.
}
/**
* FilterGroup renders a collapsible accordion section for a single filter.
* Supports checkbox selection and "Show more" for long lists.
*/
export function FilterGroup({ filter, activeValues }: FilterGroupProps) {
const [isOpen, setIsOpen] = useState(true); // Accordion open/closed state.
const [showAll, setShowAll] = useState(false); // Show all vs truncated list.
const toggleFilter = useCollection((state) => state.toggleFilter);
// Limit initial visible items to prevent overwhelming UI.
const visibleCount = 5;
const visibleValues = showAll ? filter.values : filter.values.slice(0, visibleCount);
const hasMore = filter.values.length > visibleCount;
// Toggle a filter value on/off.
const handleToggle = (value: string) => {
toggleFilter(filter.id, value);
};
return (
<div className={styles.group}>
{/* Accordion header - clickable to expand/collapse */}
<button
type="button"
className={styles.header}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen} // Accessibility: communicate state.
>
<span className={styles.label}>
{filter.label}
{/* Show count of active selections in the header */}
{activeValues.length > 0 && (
<span className={styles.count}>({activeValues.length})</span>
)}
</span>
<ChevronIcon className={`${styles.chevron} ${isOpen ? styles.open : ''}`} />
</button>
{/* Collapsible content area */}
{isOpen && (
<div className={styles.content}>
<ul className={styles.list}>
{visibleValues.map((value) => {
const isActive = activeValues.includes(value.value);
return (
<li key={value.value}>
{/* Checkbox filter option */}
<label className={styles.option}>
<input
type="checkbox"
checked={isActive}
onChange={() => handleToggle(value.value)}
className={styles.checkbox}
/>
<span className={styles.optionLabel}>{value.label}</span>
{/* Product count helps users gauge filter effectiveness */}
<span className={styles.optionCount}>({value.count})</span>
</label>
</li>
);
})}
</ul>
{/* "Show more/less" toggle for long filter lists */}
{hasMore && (
<button
type="button"
className={styles.showMore}
onClick={() => setShowAll(!showAll)}
>
{showAll ? 'Show less' : `Show ${filter.values.length - visibleCount} more`}
</button>
)}
</div>
)}
</div>
);
}
/**
* Chevron icon that rotates when accordion is open.
*/
function ChevronIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M4 6l4 4 4-4" />
</svg>
);
}
src/components/collection/FilterSidebar/FilterGroup.module.css
.group {
border-bottom: 1px solid var(--color-border);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 1rem 0;
border: none;
background: none;
text-align: left;
cursor: pointer;
}
.label {
font-size: 0.9375rem;
font-weight: 500;
}
.count {
margin-left: 0.25rem;
font-weight: 400;
color: var(--color-text-muted);
}
.chevron {
transition: transform 0.2s ease;
}
.chevron.open {
transform: rotate(180deg);
}
.content {
padding-bottom: 1rem;
}
.list {
margin: 0;
padding: 0;
list-style: none;
}
.option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0;
cursor: pointer;
}
.checkbox {
width: 18px;
height: 18px;
margin: 0;
accent-color: var(--color-primary);
cursor: pointer;
}
.optionLabel {
flex: 1;
font-size: 0.875rem;
}
.optionCount {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.showMore {
padding: 0;
margin-top: 0.5rem;
border: none;
background: none;
font-size: 0.8125rem;
color: var(--color-text-muted);
text-decoration: underline;
cursor: pointer;
}
.showMore:hover {
color: var(--color-text);
}

Price Range Filter

A dual-handle range slider for price:

src/components/collection/FilterSidebar/PriceRangeFilter.tsx
import { useState, useEffect, useCallback } from 'react';
import { useCollection } from '@/stores/collection';
import type { Filter } from '@/types/collection';
import { formatMoney } from '@/utils/money';
import { debounce } from '@/utils/debounce'; // Utility to delay function execution.
import styles from './PriceRangeFilter.module.css';
interface PriceRangeFilterProps {
filter: Filter; // Price range filter from Shopify.
}
/**
* PriceRangeFilter provides a dual-handle slider for filtering by price.
* Uses debounced updates to avoid excessive API calls while sliding.
*/
export function PriceRangeFilter({ filter }: PriceRangeFilterProps) {
const [isOpen, setIsOpen] = useState(true); // Accordion state.
const activeFilters = useCollection((state) => state.activeFilters);
const setPriceRange = useCollection((state) => state.setPriceRange);
// Extract min/max bounds from Shopify's filter data.
const priceData = filter.values[0] as unknown as { min: number; max: number };
const minPrice = priceData?.min || 0;
const maxPrice = priceData?.max || 1000;
// Get current values from URL params or default to full range.
const currentMin = activeFilters['price.gte']?.[0]
? parseInt(activeFilters['price.gte'][0], 10)
: minPrice;
const currentMax = activeFilters['price.lte']?.[0]
? parseInt(activeFilters['price.lte'][0], 10)
: maxPrice;
// Local state for immediate UI feedback during slider drag.
const [localMin, setLocalMin] = useState(currentMin);
const [localMax, setLocalMax] = useState(currentMax);
// Debounced update to avoid calling API on every slider movement.
// Waits 500ms after last change before triggering fetch.
const debouncedUpdate = useCallback(
debounce((min: number, max: number) => {
setPriceRange(min, max);
}, 500),
[setPriceRange]
);
// Handle minimum price slider change.
const handleMinChange = (event: React.ChangeEvent<HTMLInputElement>) => {
// Ensure min doesn't exceed max - 1 (prevent overlap).
const value = Math.min(parseInt(event.target.value, 10), localMax - 1);
setLocalMin(value); // Update local state immediately for smooth UI.
debouncedUpdate(value, localMax); // Queue debounced API call.
};
// Handle maximum price slider change.
const handleMaxChange = (event: React.ChangeEvent<HTMLInputElement>) => {
// Ensure max doesn't go below min + 1 (prevent overlap).
const value = Math.max(parseInt(event.target.value, 10), localMin + 1);
setLocalMax(value); // Update local state immediately.
debouncedUpdate(localMin, value); // Queue debounced API call.
};
// Calculate thumb positions as percentages for the highlight track.
const minPercent = ((localMin - minPrice) / (maxPrice - minPrice)) * 100;
const maxPercent = ((localMax - minPrice) / (maxPrice - minPrice)) * 100;
return (
<div className={styles.group}>
{/* Collapsible header */}
<button
type="button"
className={styles.header}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
>
<span className={styles.label}>Price</span>
<ChevronIcon className={`${styles.chevron} ${isOpen ? styles.open : ''}`} />
</button>
{isOpen && (
<div className={styles.content}>
{/* Dual-range slider container */}
<div className={styles.slider}>
{/* Colored track between the two handles */}
<div
className={styles.track}
style={{
left: `${minPercent}%`,
right: `${100 - maxPercent}%`,
}}
/>
{/* Minimum price handle */}
<input
type="range"
min={minPrice}
max={maxPrice}
value={localMin}
onChange={handleMinChange}
className={styles.thumb}
aria-label="Minimum price"
/>
{/* Maximum price handle */}
<input
type="range"
min={minPrice}
max={maxPrice}
value={localMax}
onChange={handleMaxChange}
className={styles.thumb}
aria-label="Maximum price"
/>
</div>
{/* Display formatted price values below slider */}
<div className={styles.values}>
<span>{formatMoney(localMin * 100)}</span>
<span>{formatMoney(localMax * 100)}</span>
</div>
</div>
)}
</div>
);
}
src/components/collection/FilterSidebar/PriceRangeFilter.module.css
.group {
border-bottom: 1px solid var(--color-border);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 1rem 0;
border: none;
background: none;
text-align: left;
cursor: pointer;
}
.label {
font-size: 0.9375rem;
font-weight: 500;
}
.chevron {
transition: transform 0.2s ease;
}
.chevron.open {
transform: rotate(180deg);
}
.content {
padding-bottom: 1.5rem;
}
.slider {
position: relative;
height: 6px;
margin: 1rem 0;
background: var(--color-border);
border-radius: 3px;
}
.track {
position: absolute;
top: 0;
bottom: 0;
background: var(--color-primary);
border-radius: 3px;
}
.thumb {
position: absolute;
width: 100%;
height: 6px;
top: 0;
pointer-events: none;
appearance: none;
background: transparent;
}
.thumb::-webkit-slider-thumb {
pointer-events: all;
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid var(--color-primary);
background: var(--color-background);
cursor: pointer;
appearance: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.thumb::-moz-range-thumb {
pointer-events: all;
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid var(--color-primary);
background: var(--color-background);
cursor: pointer;
}
.values {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
color: var(--color-text-muted);
}

Active Filters Component

Show applied filters as removable chips:

src/components/collection/ActiveFilters/ActiveFilters.tsx
import { useCollection } from '@/stores/collection';
import styles from './ActiveFilters.module.css';
/**
* ActiveFilters displays currently applied filters as removable chips.
* Provides a quick way to see and remove individual filters.
*/
export function ActiveFilters() {
const activeFilters = useCollection((state) => state.activeFilters);
const filters = useCollection((state) => state.filters);
const toggleFilter = useCollection((state) => state.toggleFilter);
const clearAllFilters = useCollection((state) => state.clearAllFilters);
// Transform the active filters map into a flat array of chip objects.
// Each chip needs both the filter context and value for display and removal.
const chips: Array<{
filterId: string;
filterLabel: string;
value: string;
valueLabel: string;
}> = [];
Object.entries(activeFilters).forEach(([filterId, values]) => {
// Find the filter definition to get the label.
const filter = filters.find((filterItem) => filterItem.id === filterId);
if (!filter) return;
// Create a chip for each selected value in this filter.
values.forEach((value) => {
const filterValue = filter.values.find((filterValueItem) => filterValueItem.value === value);
chips.push({
filterId,
filterLabel: filter.label,
value,
valueLabel: filterValue?.label || value, // Fallback to raw value if label not found.
});
});
});
// Don't render anything if no filters are active.
if (chips.length === 0) {
return null;
}
return (
<div className={styles.container}>
<span className={styles.label}>Active filters:</span>
<div className={styles.chips}>
{/* Render each filter chip as a clickable button */}
{chips.map((chip) => (
<button
key={`${chip.filterId}-${chip.value}`}
type="button"
className={styles.chip}
onClick={() => toggleFilter(chip.filterId, chip.value)} // Toggle removes the filter.
aria-label={`Remove ${chip.filterLabel}: ${chip.valueLabel}`} // Accessibility.
>
<span className={styles.chipLabel}>
{chip.filterLabel}: {chip.valueLabel}
</span>
<CloseIcon className={styles.chipClose} />
</button>
))}
{/* Quick "Clear all" button at the end */}
<button
type="button"
className={styles.clearAll}
onClick={clearAllFilters}
>
Clear all
</button>
</div>
</div>
);
}
/**
* Small X icon for chip removal buttons.
*/
function CloseIcon({ className }: { className?: string }) {
return (
<svg className={className} width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<path d="M11.854 2.146a.5.5 0 0 1 0 .708L7.707 7l4.147 4.146a.5.5 0 0 1-.708.708L7 7.707l-4.146 4.147a.5.5 0 0 1-.708-.708L6.293 7 2.146 2.854a.5.5 0 1 1 .708-.708L7 6.293l4.146-4.147a.5.5 0 0 1 .708 0z" />
</svg>
);
}
src/components/collection/ActiveFilters/ActiveFilters.module.css
.container {
display: flex;
align-items: flex-start;
gap: 0.75rem;
flex-wrap: wrap;
}
.label {
font-size: 0.875rem;
color: var(--color-text-muted);
padding-top: 0.25rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
font-size: 0.8125rem;
cursor: pointer;
transition: border-color 0.15s ease;
}
.chip:hover {
border-color: var(--color-text);
}
.chipLabel {
color: var(--color-text);
}
.chipClose {
color: var(--color-text-muted);
}
.chip:hover .chipClose {
color: var(--color-text);
}
.clearAll {
padding: 0.25rem 0;
border: none;
background: none;
font-size: 0.8125rem;
color: var(--color-text-muted);
text-decoration: underline;
cursor: pointer;
}
.clearAll:hover {
color: var(--color-text);
}

Mobile Filter Drawer

On mobile, show filters in a drawer:

src/components/collection/FilterDrawer/FilterDrawer.tsx
import { useUI } from '@/stores/ui'; // Global UI state for drawers/modals.
import { Drawer } from '@/components/ui'; // Reusable drawer component.
import { FilterSidebar } from '../FilterSidebar';
import { Button } from '@/components/ui';
import { useCollection } from '@/stores/collection';
import styles from './FilterDrawer.module.css';
/**
* FilterDrawer renders filters in a slide-in drawer on mobile.
* Replaces the sidebar on smaller screens for better UX.
*/
export function FilterDrawer() {
// Check if this specific drawer is open (only one drawer can be open at a time).
const isOpen = useUI((state) => state.activeDrawer === 'filter');
const closeDrawer = useUI((state) => state.closeDrawer);
const activeFilters = useCollection((state) => state.activeFilters);
const totalProducts = useCollection((state) => state.totalProducts);
// Count total active filter selections for the button label.
const filterCount = Object.values(activeFilters).flat().length;
return (
<Drawer isOpen={isOpen} onClose={closeDrawer} position="left" title="Filters">
{/* Scrollable filter content area */}
<div className={styles.content}>
<FilterSidebar />
</div>
{/* Sticky footer with apply button */}
<div className={styles.footer}>
<Button variant="primary" fullWidth onClick={closeDrawer}>
{/* Dynamic label shows product count and active filter count */}
Show {totalProducts} products
{filterCount > 0 && ` (${filterCount} filters)`}
</Button>
</div>
</Drawer>
);
}

Key Takeaways

  1. Shopify filter API: Use filter.v.option.* URL patterns
  2. Accordion groups: Collapsible sections for each filter type
  3. Price range slider: Dual-handle for min/max with debounce
  4. Active filter chips: Easy way to see and remove filters
  5. URL synchronization: Filters persist in URL for sharing
  6. Mobile drawer: Full-screen filter UI on small screens
  7. Show counts: Display product count per filter value

In the next lesson, we’ll build the Sorting and Pagination components.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...