webdevpuneet.com

Faceted Filter Sidebar — HTML CSS JS Snippet

Faceted Filter Sidebar · Forms · Plain HTML, CSS & JS · Live preview

CategoryForms

Copy & paste

Source Code

<div class="catalog">
  <aside class="filters" id="filters">
    <div class="filters-head">
      <span class="filters-title">Filters</span>
      <button class="clear-all" id="clearAll" onclick="clearAll()">Clear all</button>
    </div>

    <div class="chips" id="activeChips"></div>

    <div class="facet open">
      <button class="facet-head" onclick="toggleFacet(this)">
        <span>Category</span>
        <svg class="caret" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
      </button>
      <div class="facet-body">
        <label class="check"><input type="checkbox" value="Sneakers" onchange="onFacet(this,'Category')"><span class="box"></span>Sneakers<em>128</em></label>
        <label class="check"><input type="checkbox" value="Boots" onchange="onFacet(this,'Category')"><span class="box"></span>Boots<em>64</em></label>
        <label class="check"><input type="checkbox" value="Sandals" onchange="onFacet(this,'Category')"><span class="box"></span>Sandals<em>41</em></label>
        <label class="check"><input type="checkbox" value="Loafers" onchange="onFacet(this,'Category')"><span class="box"></span>Loafers<em>27</em></label>
      </div>
    </div>

    <div class="facet open">
      <button class="facet-head" onclick="toggleFacet(this)">
        <span>Price</span>
        <svg class="caret" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
      </button>
      <div class="facet-body">
        <div class="price-vals"><span id="priceMinVal">$0</span><span id="priceMaxVal">$300</span></div>
        <div class="range">
          <div class="range-track"><div class="range-fill" id="rangeFill"></div></div>
          <input type="range" id="priceMin" min="0" max="300" value="0" step="10" oninput="onPrice()">
          <input type="range" id="priceMax" min="0" max="300" value="300" step="10" oninput="onPrice()">
        </div>
      </div>
    </div>

    <div class="facet open">
      <button class="facet-head" onclick="toggleFacet(this)">
        <span>Color</span>
        <svg class="caret" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
      </button>
      <div class="facet-body">
        <div class="swatches">
          <button class="swatch" style="--c:#1e293b" data-color="Black" onclick="onSwatch(this)" title="Black"></button>
          <button class="swatch" style="--c:#ffffff" data-color="White" onclick="onSwatch(this)" title="White"></button>
          <button class="swatch" style="--c:#ef4444" data-color="Red" onclick="onSwatch(this)" title="Red"></button>
          <button class="swatch" style="--c:#3b82f6" data-color="Blue" onclick="onSwatch(this)" title="Blue"></button>
          <button class="swatch" style="--c:#10b981" data-color="Green" onclick="onSwatch(this)" title="Green"></button>
          <button class="swatch" style="--c:#f59e0b" data-color="Amber" onclick="onSwatch(this)" title="Amber"></button>
        </div>
      </div>
    </div>

    <div class="facet open">
      <button class="facet-head" onclick="toggleFacet(this)">
        <span>Rating</span>
        <svg class="caret" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
      </button>
      <div class="facet-body">
        <label class="rate"><input type="radio" name="rating" value="4" onchange="onRating(this)"><span class="stars" data-n="4"></span>& up</label>
        <label class="rate"><input type="radio" name="rating" value="3" onchange="onRating(this)"><span class="stars" data-n="3"></span>& up</label>
        <label class="rate"><input type="radio" name="rating" value="2" onchange="onRating(this)"><span class="stars" data-n="2"></span>& up</label>
      </div>
    </div>
  </aside>

  <div class="results">
    <div class="results-head"><span id="resultCount">260</span> products match</div>
    <div class="results-note">Apply filters on the left — active selections appear as removable chips at the top of the sidebar.</div>
  </div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Single-state render model
One state object holds every selection; a single render function rebuilds chips, the clear-all button, and the result count — unidirectional flow in vanilla JS.
Collapsible facet groups
toggleFacet toggles an .open class; CSS animates max-height to 0 and rotates the caret, so unknown-height panels still transition smoothly.
Dual-handle price range
Two stacked range inputs with pointer-events only on the thumbs; onPrice enforces a minimum gap and paints the selected band via left/right percentages.
Custom-property colour swatches
Each swatch reads its fill from a --c variable, so a single CSS rule styles every colour and the active ring uses a layered box-shadow.
Pure-CSS partial star rating
.stars::before clips a linear-gradient to text at --n × 20% — fractional star fills with no SVG or image assets.
Removable active-filter chips
render rebuilds the chip row from state; each chip's × calls removeChip(key, value) to clear exactly one selection and re-sync its input.
Conditional clear-all
The Clear-all control fades in via a .show class only when one or more filters are active, then resets the entire state and all inputs.
Live result count
The count decays per active facet so filtering feels instant; swap the formula for your filtered array length or a server count in production.

About this UI Snippet

Faceted Filter Sidebar — Collapsible Facets, Dual Range Slider, Colour Swatches & Active-Filter Chips

Screenshot of the Faceted Filter Sidebar snippet rendered live

Faceted search is the backbone of every catalogue, marketplace, and product listing page. When a shop has thousands of items, users do not scroll — they filter. A faceted filter sidebar lets shoppers narrow results by combining independent dimensions (category, price, colour, rating) and instantly see how many products match. This snippet implements the complete pattern in plain HTML, CSS, and vanilla JavaScript: collapsible facet groups, a dual-handle price range, clickable colour swatches, a star-rating filter, removable active-filter chips, and a clear-all action.

The single most important UX principle in faceted filtering is feedback: every selection must immediately update both the visible "active filters" summary and the result count. This snippet keeps a single state object as the source of truth and re-derives the whole UI from it in one render function — the same unidirectional-data-flow idea React popularised, written in vanilla JS.

Collapsible facet groups

Each facet is a .facet block with an .open class. The toggleFacet function toggles that class on click; the CSS animates max-height from its open value to 0 and rotates the caret svg 90 degrees. Using max-height (rather than height) lets the panel collapse with a transition even though its natural height is unknown. Long facet lists stay scannable because users can fold the groups they are not using.

Dual-handle price range

The price filter stacks two <input type="range"> elements on the same track. Both have pointer-events: none so the track shows through, and only the thumbs re-enable pointer events via ::-webkit-slider-thumb. The onPrice function reads both values, enforces a minimum gap so the handles cannot cross (min > max - 10), and sets the .range-fill element's left and right as percentages to paint the selected band. This is the standard two-thumb slider technique that avoids any library.

Colour swatches and star rating

Swatches are buttons whose colour comes from a --c CSS custom property, so one rule styles every chip. Clicking toggles an .active ring and adds or removes the colour from state.Color. The rating facet uses radio inputs and a pure-CSS star bar: .stars::before renders five glyphs and a linear-gradient clipped to text fills them to --n × 20%, giving partial-star precision without images.

Active-filter chips and clear-all

After every change, render rebuilds the chip row from state. Each chip carries a removeChip(key, value) handler that clears just that selection — unchecking the matching input or resetting the price — then re-renders. The Clear-all button only appears (.show) when at least one filter is active. The mock result count decays with each active facet so the count feels responsive; in production you would swap that for the length of your filtered dataset or an API response.

Pair this sidebar with a product card grid, a chip filter bar for quick toggles, or a pagination table to page through matches.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSA sidebar appears with four facet groups — Category, Price, Colour, Rating — beside a results panel showing "260 products match".
  2. 2
    Collapse a facetClick any group heading — the panel folds with a smooth max-height transition and the caret rotates. Click again to expand it.
  3. 3
    Tick category boxesCheck Sneakers and Boots — each adds a removable chip at the top of the sidebar and the result count drops.
  4. 4
    Drag the price handlesMove the two range thumbs — the purple fill band tracks between them and the dollar labels update. The handles cannot cross.
  5. 5
    Pick colours and a ratingClick colour swatches to ring them, then choose "4★ & up" — both appear as chips and the count recalculates.
  6. 6
    Remove or clear filtersClick the × on any chip to drop that filter, or hit "Clear all" (it only shows when filters are active) to reset everything.

Real-world uses

Common Use Cases

E-commerce category pages
The primary use case — narrow a shoe, apparel, or electronics catalogue by category, price, colour, and rating. Drop it beside a product card grid.
Marketplace and listing search
Property, car, and job marketplaces use faceted sidebars to combine many filter dimensions. Pair the sidebar with a data table of results.
Documentation and content libraries
Filter articles or components by tag, type, and difficulty. Combine with a search box for free-text plus facet filtering.
Admin dashboards and reports
Refine large record sets by status, owner, date, and rating before exporting. Works alongside a pagination table for paged results.
Travel and booking sites
Filter hotels or flights by price band, star rating, and amenity colour tags — the dual range slider maps directly to nightly-price filtering.
Mobile filter sheets
On narrow screens the sidebar stacks above results; move it into a bottom sheet triggered by a "Filters" button for mobile catalogues.
Related: Interest Selector
See the Interest Selector for a related forms pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

In render, replace the mock count formula with a filter over your dataset: keep an array of products and run products.filter(p => state.Category.length === 0 || state.Category.includes(p.category)) and similar checks for colour, price band, and rating. Render the matching items into the results panel and set the count to the filtered length.

On each render, serialise state into query params with URLSearchParams and call history.replaceState. On load, read the params back and pre-check the matching inputs before the first render. This makes filtered views shareable and bookmarkable — essential for SEO and paid traffic landing pages.

For facets with many options (brands, sizes), add an <input type="text"> above the list and filter the visible .check rows on oninput by matching the label text. Hide non-matching rows with display:none so users can type to find an option instead of scrolling.

Add aria-label="Minimum price" and aria-label="Maximum price" to the two range inputs and aria-valuetext updated in onPrice so screen readers announce dollar amounts. Range inputs are keyboard-operable by default (arrow keys), and the minimum-gap guard prevents an invalid inverted range.

Yes. In React, move state into useState and render chips and counts from it directly — no manual render call needed. In Vue, make state reactive with ref/reactive and bind inputs with v-model. In Angular, hold state on the component and use (change)/(input) bindings. The dual-range CSS and star-bar trick port unchanged.