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 hereSee Collection Page Architecture for the complete Liquid section setup and filter data serialization from
collection.filters.
Data Source
| Prop/State | Source | Liquid Field |
|---|---|---|
filters | Zustand store (from JSON) | collection.filters |
activeFilters | Parsed from URL | URL params + collection.filters |
products | Zustand store | collection.products (refetched on filter) |
isLoading | Local 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=100The collection.filters object gives us available filters with their values and counts.
Filter Types
Shopify supports several filter types:
| Type | URL Pattern | Example |
|---|---|---|
| List (options) | filter.v.option.{name} | filter.v.option.color=Blue |
| Boolean | filter.p.m.{metafield} | filter.p.m.custom.sale=true |
| Price range | filter.p.price.gte/lte | filter.p.price.gte=50 |
| Availability | filter.v.availability | filter.v.availability=1 |
| Product type | filter.p.product_type | filter.p.product_type=Shirt |
| Vendor | filter.p.vendor | filter.p.vendor=Nike |
Filter Sidebar Component
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> );}.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:
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> );}.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:
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> );}.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:
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> );}.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:
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
- Shopify filter API: Use
filter.v.option.*URL patterns - Accordion groups: Collapsible sections for each filter type
- Price range slider: Dual-handle for min/max with debounce
- Active filter chips: Easy way to see and remove filters
- URL synchronization: Filters persist in URL for sharing
- Mobile drawer: Full-screen filter UI on small screens
- 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...