
Faceted Navigation
- 64 installs
- 41 repo stars
- Updated March 13, 2026
- finsilabs/awesome-ecommerce-skills
Build a filterable product listing page with multi-attribute facets, URL-shareable filter state, and mobile-friendly controls.
About
Builds faceted product filtering that keeps the URL in sync so filters are shareable, bookmarkable, and crawlable. A developer uses it when a catalog exceeds ~50 SKUs or when building/redesigning a category listing page.
- Per-platform faceting approach table
- Crawlable faceted URLs and back-button-safe state, optionally via Algolia InstantSearch
Faceted Navigation by the numbers
- 64 all-time installs (skills.sh)
- Ranked #1,184 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/finsilabs/awesome-ecommerce-skills --skill faceted-navigationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 64 |
|---|---|
| repo stars | ★ 41 |
| Last updated | March 13, 2026 |
| Repository | finsilabs/awesome-ecommerce-skills ↗ |
What it does
Build a filterable product listing page with multi-attribute facets, URL-shareable filter state, and mobile-friendly controls.
Files
Faceted Navigation
Overview
Build a filterable product listing page (PLP) where shoppers can narrow results by multiple attributes simultaneously — size, color, brand, price range, rating — while keeping the URL in sync so filters are shareable, bookmarkable, and crawlable by search engines. Good faceted navigation drives measurable conversion lift on catalogs with more than 50 SKUs.
When to Use This Skill
- When a product catalog exceeds ~50 SKUs and browse-only navigation causes shopper frustration
- When building or redesigning a category/collection listing page
- When SEO requires crawlable faceted URLs (e.g.,
/shoes/running?color=black&size=10) - When replacing a legacy faceted implementation that breaks the back button
- When implementing Algolia InstantSearch or a custom faceting layer on Elasticsearch
Core Instructions
Step 1: Determine the merchant's platform and choose the right approach
| Platform | Recommended Approach | Why |
|---|---|---|
| Shopify | Enable native collection filters (Online Store 2.0 themes) + install Search & Discovery app (free) | Dawn, Sense, and other OS2.0 themes support native storefront filtering via the Storefront API; Search & Discovery adds synonym, boosting, and filter configuration from the admin |
| WooCommerce | Install FiboSearch (paid, $49/yr) or WooCommerce Product Filters (free) plugin | FiboSearch adds AJAX-powered facets with price slider, color swatches, and stock filter; the free Product Filters plugin covers basic attribute filtering with URL state |
| BigCommerce | Enable Faceted Search in Storefront → Search settings (built-in on Cornerstone and most themes) | BigCommerce Faceted Search is native to the platform — configure which product attributes appear as facets in the admin; no app required |
| Custom / Headless | Build with Algolia InstantSearch or Elasticsearch + URL state management | Full control over facet logic, disjunctive filtering, and count freshness; see implementation details below |
Step 2: Configure facets on your platform
---
Shopify
Using Search & Discovery app (recommended):
1. Install Search & Discovery from the Shopify App Store (free, by Shopify) 2. Go to Apps → Search & Discovery → Filters 3. Click Add filter and select the product attributes to use as facets (e.g., Color, Size, Brand, Price) 4. Drag to reorder filters — put the most-used ones first 5. Set Availability and Price filters to appear by default; keep variant-specific filters collapsed 6. In Online Store → Themes → Customize, ensure your theme's collection template has the Filters section enabled in the sidebar or drawer
Theme-level filter configuration (Dawn theme): 1. Go to Online Store → Themes → Customize 2. Navigate to a collection page template 3. In the sidebar, click Product grid and enable Enable filtering and Enable sorting 4. Set Filter layout to Drawer (recommended for mobile) or Sidebar (desktop)
For Shopify Plus: Use Shopify Flow to tag products with filterable attributes automatically when new products are added.
---
WooCommerce
Option A: FiboSearch (recommended for advanced filtering): 1. Install FiboSearch – AJAX Search for WooCommerce from WordPress.org or the plugin marketplace 2. Go to FiboSearch → Settings → Filters and enable the filter bar 3. Configure which attributes appear as facets: product categories, price range, custom attributes (e.g., Color, Size), and stock status 4. Set Filter style to checkbox list or color swatches per attribute 5. Enable AJAX filtering so results update without page reload 6. Place the filter widget via Appearance → Widgets → Shop Sidebar or in a Gutenberg block
Option B: WooCommerce Product Filters (free): 1. Install from WordPress.org 2. Go to Products → Filters → Add New Filter Group 3. Add filters for: Category, Attribute (e.g., Color, Size), Price range, Product tag, Stock status 4. Place the filter shortcode [woof] in your shop page sidebar or above the product grid
URL state: Both plugins write active filters to the URL as query parameters, making results shareable and bookmarkable.
---
BigCommerce
1. Go to Storefront → Search in your BigCommerce control panel 2. Under Faceted Search, toggle it On 3. Select which product fields appear as facets: Brand, Category, Price, Rating, and any custom Product Custom Fields 4. Set the maximum number of values shown per facet before a "Show more" link appears 5. For price facets, configure the price range buckets (e.g., $0-$25, $25-$50, $50-$100) 6. In your theme settings (Storefront → My Themes → Customize), configure the filter sidebar layout and mobile drawer behavior
Note: Faceted Search requires Stencil-based themes (Cornerstone and most modern BigCommerce themes). Legacy Blueprint themes do not support it.
---
Custom / Headless
For headless storefronts, implement URL-state-driven filtering with your search backend:
URL state schema — all active filters live in query params:
/products/shoes?brand=Nike&brand=Adidas&color=black&size=10&price_min=50&price_max=150&sort=price_asc&page=1Parse and build URL state:
// lib/facetUrl.js
export function parseFiltersFromUrl(searchParams) {
const filters = {};
for (const [key, value] of searchParams.entries()) {
if (['sort', 'page', 'q'].includes(key)) continue;
if (!filters[key]) filters[key] = [];
filters[key].push(value);
}
return filters;
}
export function buildUrlFromFilters(filters, sort, page) {
const params = new URLSearchParams();
for (const [facetKey, values] of Object.entries(filters)) {
values.forEach(v => params.append(facetKey, v));
}
if (sort) params.set('sort', sort);
if (page && page > 1) params.set('page', String(page));
return `?${params.toString()}`;
}Algolia query with disjunctive facets (OR within a facet, AND between facets):
const result = await index.search(query ?? '', {
facets: ['brand', 'color', 'size', 'price_range'],
// facetFilters = OR within a facet group, AND between groups
facetFilters: Object.entries(filters)
.filter(([k]) => !['price_min', 'price_max'].includes(k))
.map(([key, values]) => values.map(v => `${key}:${v}`)),
filters: [
filters.price_min ? `price >= ${filters.price_min[0]}` : null,
filters.price_max ? `price <= ${filters.price_max[0]}` : null,
].filter(Boolean).join(' AND '),
hitsPerPage: 24,
page: page - 1,
});Toggle filter and push URL state:
function toggleFilter(facetKey, value, currentFilters, sort) {
const current = currentFilters[facetKey] ?? [];
const next = current.includes(value)
? current.filter(v => v !== value)
: [...current, value];
const updated = next.length
? { ...currentFilters, [facetKey]: next }
: (({ [facetKey]: _, ...rest }) => rest)(currentFilters);
// Push new URL — each filter change gets its own history entry for back-button support
window.history.pushState({}, '', buildUrlFromFilters(updated, sort, 1));
return updated;
}Step 3: Configure mobile filter experience
On mobile, the filter panel should appear as a bottom drawer triggered by a "Filter" button — not an always-visible sidebar that takes up half the screen.
- Shopify (Dawn): Set Filter layout to Drawer in the Theme Customizer — this is already the default on mobile
- WooCommerce (FiboSearch): Enable Mobile filter button in FiboSearch settings; the plugin adds a collapsible filter panel
- BigCommerce: In theme settings, enable Filter drawer on mobile; Cornerstone handles this natively
- Custom: Render filters in a
position: fixed; bottom: 0drawer on screens below 640px; trigger with a "Filter (N)" button where N is the active filter count
Step 4: Add active filter pills and clear controls
All platforms should show applied filters as dismissible pills above the product grid:
- Shopify (Search & Discovery): Active filter pills are built into the OS2.0 filter section — enable them in the Theme Customizer
- WooCommerce: FiboSearch shows active filter tags by default; add
[woof_active_filters]shortcode to show them separately - BigCommerce: Cornerstone displays active refinements automatically; add the Active Facets section to your template if missing
- Custom: Render pills from URL filter state; each pill has an × button that calls
toggleFilterto remove it
Best Practices
- Encode filter state in the URL — never store active filters only in JavaScript state; the URL is the source of truth for sharing, bookmarking, and back-button behavior
- Use disjunctive facets within a group — selecting Nike AND Adidas should show products from either brand, not an empty intersection
- Return facet counts in every response — counts must reflect the current filter context, not the global catalog
- Limit facet overflow to top 10 values — show a "Show more" toggle to prevent overwhelming mobile users
- Debounce price range sliders — commit the range only on mouseup/touchend, not on every tick
- Add a "Clear all" affordance — always visible when any filter is active
Common Pitfalls
| Problem | Solution |
|---|---|
| Back button reloads page instead of removing last filter | Use history.pushState (not replaceState) for each filter change |
| Facet counts go stale after first filter selection | Re-fetch facet counts on every filter change using Algolia disjunctive faceting or Search & Discovery's built-in count refresh |
| Mobile filter panel overlaps content | Use a drawer/modal on mobile triggered by a "Filters" button; never show a sidebar on screens below 640px |
| SEO duplicate content from facet URLs | Add rel="noindex" or canonical tags for non-primary facet combinations; this is configured in Shopify's Search & Discovery SEO settings |
| Price range thumbs overlap | Clamp max thumb minimum to current min+step and min thumb maximum to current max-step on every change |
Related Skills
- @search-autocomplete
- @product-categorization
- @accessibility-commerce
- @product-page-design
{
"context": "Tests whether the agent implements the search API with correct disjunctive facet logic, separate price range handling, and a price range component that uses a dual-thumb range input committing only on mouseup/touchend with thumb clamping.",
"type": "weighted_checklist",
"checklist": [
{
"name": "Products and facets together",
"max_score": 10,
"description": "The searchProducts function returns both products (hits) AND facet counts in a single response object — not as two separate calls"
},
{
"name": "Disjunctive OR within facet",
"max_score": 12,
"description": "The facet filter builder groups values within the same facet key together as OR alternatives (e.g., [[brand:Nike, brand:Adidas], [color:black]]) rather than AND conditions"
},
{
"name": "AND between facets",
"max_score": 10,
"description": "Filters from different facet keys are combined with AND logic (each facet group is a separate element in the filter array)"
},
{
"name": "Price as numeric filter",
"max_score": 10,
"description": "Price min/max are applied as numeric comparison strings (e.g., 'price >= 50', 'price <= 150') NOT as facetFilters entries"
},
{
"name": "Price excluded from facetFilters",
"max_score": 8,
"description": "price_min and price_max keys are excluded from the facetFilters array used for checkbox facets"
},
{
"name": "Correct hits-per-page",
"max_score": 6,
"description": "The search query uses hitsPerPage of 24 (from the config constant or hardcoded as 24)"
},
{
"name": "Dual-thumb range inputs",
"max_score": 10,
"description": "PriceRangeFacet renders two separate <input type='range'> elements (one for min, one for max), not a single range or checkboxes"
},
{
"name": "Commit on mouseup/touchend",
"max_score": 12,
"description": "The price range component fires its callback (or commits the value) in onMouseUp and onTouchEnd handlers, NOT in onChange"
},
{
"name": "Thumb clamping",
"max_score": 12,
"description": "The price range component clamps the min thumb's maximum to current max value and the max thumb's minimum to current min value to prevent the thumbs from crossing/overlapping"
},
{
"name": "Design notes document",
"max_score": 10,
"description": "DESIGN_NOTES.md explains the OR-within AND-between facet logic, price range separate handling, and mouseup/touchend debounce strategy"
}
]
}
Product Search API and Price Range Filter
Problem/Feature Description
Trailhead Market, an outdoor gear e-commerce site, is building a new filtered product listing page. The engineering team needs two pieces: (1) a search API module that queries their Algolia index for products while applying facet filters correctly, and (2) a standalone price range UI component for the filter sidebar. The team ran into problems with their previous implementation — after a shopper selected a brand filter, the facet counts for other brands dropped to zero because the API was applying the brand filter to count lookups too. They also had reports of the price slider sending dozens of API requests per second as the user dragged it.
Your job is to build the search API module and the price range component. The search API should accept the current filter state (multi-select facets and a price range) and return products along with updated facet counts. The price range component should let users drag two handles to set a minimum and maximum price and only fire its callback when the user finishes adjusting the slider.
Output Specification
Produce the following files:
api/searchProducts.js— The search module (can use mock/stub Algolia calls if no real index is available — focus on the correct structure and filter-building logic)components/PriceRangeFacet.jsx— The dual-handle price range componentDESIGN_NOTES.md— Document explaining: how facet filters are structured (OR vs AND logic), how the price range is handled differently from other facets, and how the component avoids excessive API calls
Input Files
The following data shapes show what the API should receive and return. Extract them before beginning.
=============== FILE: types/search.js =============== /**
- Input to searchProducts()
- @typedef {Object} SearchInput
- @property {Object.<string, string[]>} filters - e.g. { brand: ['Nike','Adidas'], color: ['black'] }
- price_min and price_max, if present, contain a single-element array with a numeric string
- @property {string} sort - e.g. 'price_asc', 'relevance', 'newest'
- @property {number} page - 1-indexed
- @property {string} [query] - optional text search query
*/
/**
- Output from searchProducts()
- @typedef {Object} SearchResult
- @property {Object[]} products - array of product hit objects
- @property {Object} facets - e.g. { brand: { Nike: 42, Adidas: 31 }, color: { black: 55 } }
- @property {number} totalCount
- @property {number} totalPages
*/
=============== FILE: config/algolia.js =============== // Stub — replace with real credentials in production export const ALGOLIA_APP_ID = 'YOUR_APP_ID'; export const ALGOLIA_SEARCH_KEY = 'YOUR_SEARCH_KEY'; export const ALGOLIA_INDEX_NAME = 'products';
export const FACET_FIELDS = ['brand', 'color', 'size', 'price_range', 'rating']; export const HITS_PER_PAGE = 24;
{
"context": "Tests whether the agent builds the FacetPanel with correct accessibility attributes, count-based sorting, zero-count disabling, overflow limiting, active count badges, collapsible groups, and the ActiveFilterPills component with clear-all support.",
"type": "weighted_checklist",
"checklist": [
{
"name": "Aria-label on aside",
"max_score": 6,
"description": "The FacetPanel root element is an <aside> with an aria-label attribute (e.g., 'Product filters')"
},
{
"name": "Aria-expanded on toggle",
"max_score": 8,
"description": "Each facet group has a toggle button with an aria-expanded attribute that reflects the expanded/collapsed state"
},
{
"name": "Aria-controls on toggle",
"max_score": 6,
"description": "The facet group toggle button has an aria-controls attribute referencing the id of the values list"
},
{
"name": "Role group on value list",
"max_score": 6,
"description": "The list of facet values uses role='group' and has an aria-label attribute"
},
{
"name": "Count descending sort",
"max_score": 10,
"description": "Facet values within each group are sorted by count in descending order (highest count first)"
},
{
"name": "Zero-count disabled",
"max_score": 10,
"description": "Checkboxes for values with count=0 are rendered with disabled=true, UNLESS that value is already in the active filters"
},
{
"name": "Active count badge",
"max_score": 8,
"description": "When one or more values are selected in a facet group, a badge or indicator showing the number of active selections appears on the group heading"
},
{
"name": "Expanded by default",
"max_score": 6,
"description": "Facet groups are expanded by default (not collapsed) on initial render"
},
{
"name": "Top 10 overflow limit",
"max_score": 12,
"description": "Facet groups with more than 10 values show only the top 10 by default, with a 'Show more' (or equivalent) toggle to reveal the rest"
},
{
"name": "Dismissible pills",
"max_score": 8,
"description": "ActiveFilterPills renders each active filter value as a button or element that, when clicked, removes only that individual filter value"
},
{
"name": "Clear all button",
"max_score": 10,
"description": "ActiveFilterPills renders a 'Clear all' button that is visible whenever at least one filter is active"
},
{
"name": "Pills above grid",
"max_score": 10,
"description": "COMPONENT_NOTES.md or component structure places the ActiveFilterPills display above/before the product grid, not inside the sidebar"
}
]
}
Product Filter Panel Component
Problem/Feature Description
StyleHub, an online fashion retailer, is rebuilding its product listing page to improve both usability and accessibility. Their current filter sidebar is a simple static list that doesn't show how many products match each option, makes it hard to see which filters are active, and fails several WCAG accessibility audits. Customer support frequently receives complaints from shoppers who can't tell which filters they've selected, can't remove individual filters without clearing everything, and find the sidebar overwhelming on mobile because it lists all 60+ brand options at once.
You have been asked to build the React FacetPanel component that will replace the old sidebar. The component should receive facet definitions (the list of facets to show), current facet counts from the search engine, and the currently active filter selections, and render them as an interactive filter sidebar. A separate active-filters display should appear above the product grid showing what's currently selected with the ability to remove individual selections.
Output Specification
Produce the following files:
components/FacetPanel.jsx— The main filter sidebar component with collapsible facet groupscomponents/ActiveFilterPills.jsx— The dismissible active-filter pills component shown above the product gridCOMPONENT_NOTES.md— Document covering: how zero-count values are handled, how facet values are ordered, and the overflow behavior when a facet has many values
Input Files
The following type definitions and stub data describe the expected interface. Extract them before beginning.
=============== FILE: types/facets.js =============== /**
- @typedef {Object} FacetDefinition
- @property {string} key - e.g. 'brand'
- @property {string} label - e.g. 'Brand'
*/
/**
- @typedef {Object.<string, Object.<string, number>>} FacetCounts
- example: { brand: { Nike: 42, Adidas: 31, Puma: 0 }, color: { black: 55, white: 30 } }
*/
/**
- @typedef {Object.<string, string[]>} ActiveFilters
- example: { brand: ['Nike'], color: ['black', 'white'] }
*/
=============== FILE: data/sampleFacets.js =============== export const facetDefinitions = [ { key: 'brand', label: 'Brand' }, { key: 'color', label: 'Color' }, { key: 'size', label: 'Size' }, ];
export const sampleFacetCounts = { brand: { Nike: 42, Adidas: 31, Puma: 18, Reebok: 15, 'New Balance': 12, Asics: 9, Saucony: 7, Brooks: 6, Hoka: 5, Salomon: 4, Merrell: 3, Columbia: 2, 'The North Face': 1, Patagonia: 0, }, color: { black: 55, white: 44, grey: 22, red: 18, blue: 12, green: 0 }, size: { '10': 30, '9': 28, '11': 25, '8': 20, '12': 15, '7': 8, '13': 3 }, };
export const sampleActiveFilters = { brand: ['Nike', 'Adidas'], color: ['black'], };
{
"context": "Tests whether the agent correctly implements URL-driven filter state management: encoding multi-value filters as repeated query params, parsing on mount and popstate, using pushState for history, resetting page on filter change, and cleaning up empty filter keys.",
"type": "weighted_checklist",
"checklist": [
{
"name": "Multi-value URL encoding",
"max_score": 12,
"description": "The URL builder appends multi-value filters using repeated query param keys (e.g., brand=Nike&brand=Adidas), not comma-joined or JSON-encoded values"
},
{
"name": "URL parse function",
"max_score": 10,
"description": "A parse function reads URLSearchParams entries and groups values by key into arrays, skipping reserved keys (sort, page, q)"
},
{
"name": "URL build function",
"max_score": 8,
"description": "A build function serializes a filters object back to a query string, including sort, page, and q parameters"
},
{
"name": "Mount initialization",
"max_score": 10,
"description": "The hook initializes filter state by parsing the URL query string at mount time (not from a hardcoded default empty object)"
},
{
"name": "Popstate listener",
"max_score": 12,
"description": "The hook adds a 'popstate' event listener to re-sync filter state when the browser navigates back or forward, and removes it on cleanup"
},
{
"name": "pushState not replaceState",
"max_score": 12,
"description": "Filter changes call history.pushState() to add a new history entry, NOT history.replaceState()"
},
{
"name": "Page reset on filter change",
"max_score": 8,
"description": "Toggling a filter resets the page to 1 (not preserving the current page number)"
},
{
"name": "Empty key removal",
"max_score": 10,
"description": "When the last value is removed from a facet key, that key is deleted from the filters object entirely (not left as an empty array)"
},
{
"name": "Clear all resets URL",
"max_score": 8,
"description": "The clearAll function resets the URL to '?' or removes all filter params, rather than leaving stale params in the URL"
},
{
"name": "Notes document",
"max_score": 10,
"description": "IMPLEMENTATION_NOTES.md explains multi-value URL representation, popstate handling, and empty-key removal behavior"
}
]
}
Shareable Product Filter URLs
Problem/Feature Description
The e-commerce team at Northbrook Outdoor Gear has a product listing page where shoppers can filter by brand, color, size, and price. The current implementation stores all filter selections in React component state — meaning filters are lost whenever a shopper copies and pastes the URL to share with a friend, refreshes the page, or uses the browser back button after clicking into a product detail page. The marketing team is also frustrated because they can't deep-link to pre-filtered views in email campaigns.
You have been asked to build the URL state management layer for the filtering system: a pair of utility functions for serializing/deserializing filter state to/from the URL query string, and a React hook that keeps the UI in sync with the URL. The system must handle multi-select facets (e.g., selecting both Nike and Adidas for the brand facet), support sort and page parameters alongside the filter values, and correctly restore filter state when a user navigates back with the browser.
Output Specification
Produce the following files:
lib/facetUrl.js— URL serialization utilities (parse and build functions)hooks/useFacetedNavigation.js— React hook for filter state management synchronized to the URLIMPLEMENTATION_NOTES.md— A brief document (bullet points are fine) describing: how multi-value filters are represented in the URL, how the hook handles browser back/forward navigation, and what happens when a filter's last selected value is removed
Input Files
The following partial implementation exists as a starting point. Extract it before beginning.
=============== FILE: src/ProductListingPage.jsx =============== import React from 'react';
// TODO: replace this stub with the real URL-driven hook function useStubFilters() { const [filters, setFilters] = React.useState({}); const [sort, setSort] = React.useState('relevance'); const [page, setPage] = React.useState(1);
const toggleFilter = (facetKey, value) => { setFilters(prev => { const current = prev[facetKey] ?? []; const next = current.includes(value) ? current.filter(v => v !== value) : [...current, value]; return { ...prev, [facetKey]: next }; }); };
const clearAll = () => setFilters({});
return { filters, sort, setSort, page, setPage, toggleFilter, clearAll }; }
export function ProductListingPage({ products }) { const { filters, sort, setSort, page, setPage, toggleFilter, clearAll } = useStubFilters();
return ( <div> <h1>Products</h1> <p>Active filters: {JSON.stringify(filters)}</p> <button onClick={() => toggleFilter('brand', 'Nike')}>Toggle Nike</button> <button onClick={() => toggleFilter('brand', 'Adidas')}>Toggle Adidas</button> <button onClick={() => toggleFilter('color', 'black')}>Toggle Black</button> <button onClick={clearAll}>Clear All</button> </div> ); }
{
"name": "finsi/faceted-navigation",
"version": "0.1.0",
"summary": "Build filterable product listings with multi-select facets and URL-driven state",
"skills": {
"faceted-navigation": {
"path": "SKILL.md"
}
}
}