Product Filters

Filters

Clear all
Category
Category
Price
Price range, in US dollars
Color
Color
Size
Size

@use 'sprucecss/scss/spruce' as *;

.product-filters {
  @include layout-stack('s');

  &__header {
    align-items: center;
    display: flex;
    justify-content: space-between;
  }

  &__title {
    font-size: font-size('h4');
    margin: 0;
  }

  &__clear {
    font-size: config('font-size-sm', $typography);
  }

  &__active {
    // The parent's layout-stack sets the margins.
    list-style: none;
    padding: 0;
    @include layout-cluster('xxs');

    li {
      margin: 0;
    }

    // The items sit side by side: clear the stack margins of the base list style.
    > li {
      margin-block: 0;
      padding-block: 0;
    }
  }

  &__chip {
    @include clear-btn;
    align-items: center;
    background-color: color-alpha('primary', 0.1);
    border-radius: radius('pill');
    color: color('heading');
    display: inline-flex;
    font-size: config('font-size-sm', $typography);
    gap: spacer('xxs');
    padding: 0.25em 0.5em 0.25em 0.75em;
    @include transition;

    svg {
      block-size: 0.875rem;
      inline-size: 0.875rem;
    }

    &:hover {
      background-color: color-alpha('primary', 0.2);
    }

    &:focus-visible {
      outline: 2px solid color('focus-ring');
      outline-offset: 2px;
    }
  }

  &__group {
    border: 0;
    border-block-start: config('border-width-base', $display) solid color('border');
    border-radius: 0;
    padding-block-start: spacer('xxs');

    > summary {
      padding-inline: 0;
    }

    > :not(summary) {
      padding-inline: 0;
    }

    fieldset {
      border: 0;
      margin: 0;
      padding: 0;
    }

    &--plain {
      padding-block: spacer('s') 0;
    }
  }

  &__checks {
    @include layout-stack('xs');
  }

  &__check {
    align-items: center;
    display: flex;

    .form-check__label {
      flex-grow: 1;
    }
  }

  &__count {
    color: color('muted');
    font-size: 0.75rem;
    font-variant-numeric: tabular-nums;
  }

  &__price {
    --from: 0%;
    --to: 100%;
    @include layout-stack('s');
  }

  // Two range inputs on top of each other; only their thumbs catch the pointer.
  &__range {
    block-size: 1.5rem;
    position: relative;

    &::before,
    &::after {
      block-size: 0.25rem;
      border-radius: radius('pill');
      content: '';
      inset-block-start: 50%;
      position: absolute;
      translate: 0 -50%;
    }

    &::before {
      background-color: color('range-track-background', 'form');
      inset-inline: 0;
    }

    &::after {
      background-color: color('primary');
      inset-inline: var(--from) calc(100% - var(--to));
    }

    input {
      appearance: none;
      background: none;
      block-size: 100%;
      inline-size: 100%;
      inset: 0;
      margin: 0;
      pointer-events: none;
      position: absolute;
      z-index: 1;

      &::-webkit-slider-runnable-track {
        background: none;
      }

      &::-moz-range-track {
        background: none;
      }

      &::-webkit-slider-thumb {
        appearance: none;
        background-color: color('surface');
        block-size: 1.25rem;
        border: 2px solid color('primary');
        border-radius: 50%;
        cursor: grab;
        inline-size: 1.25rem;
        pointer-events: auto;
      }

      &::-moz-range-thumb {
        background-color: color('surface');
        block-size: 1rem;
        border: 2px solid color('primary');
        border-radius: 50%;
        cursor: grab;
        inline-size: 1rem;
        pointer-events: auto;
      }
    }
  }

  &__price-inputs {
    display: grid;
    gap: spacer('s');
    grid-template-columns: 1fr 1fr;

    .form-group {
      @include layout-stack('xxs');
    }
  }

  &__colors,
  &__sizes {
    display: flex;
    flex-wrap: wrap;
    gap: spacer('xs');
  }

  &__color,
  &__size {
    cursor: pointer;
    position: relative;

    input {
      appearance: none;
      cursor: pointer;
      inset: 0;
      margin: 0;
      // Invisible but focusable and clickable; the label shows the state.
      opacity: 0;
      position: absolute;
    }
  }

  &__color {
    aspect-ratio: 1;
    background-color: var(--swatch);
    border: 2px solid color('surface');
    border-radius: radius('pill');
    box-shadow: 0 0 0 1px color('border');
    inline-size: 2rem;

    input {
      border-radius: inherit;
    }

    // The check mark of the selected color.
    &:has(:checked) {
      box-shadow: 0 0 0 2px color('heading');

      &::after {
        block-size: 0.6rem;
        // A check mark is not mirrored in right-to-left languages, so it uses physical borders.
        border-bottom: 2px solid hsl(0deg 0% 100%);
        border-right: 2px solid hsl(0deg 0% 100%);
        content: '';
        inline-size: 0.35rem;
        inset: 0;
        margin: auto;
        pointer-events: none;
        position: absolute;
        rotate: 45deg;
        translate: 0 -10%;
      }
    }

    &:has(:focus-visible) {
      outline: 2px solid color('focus-ring');
      outline-offset: 3px;
    }
  }

  &__size {
    > span {
      border: config('border-width-base', $display) solid color('border-strong');
      border-radius: radius('md');
      color: color('heading');
      display: block;
      font-size: config('font-size-sm', $typography);
      font-weight: 600;
      min-inline-size: 2.75rem;
      padding: spacer('xxs') spacer('xs');
      text-align: center;
      @include transition;
    }

    &:has(:checked) > span {
      background-color: color('heading');
      border-color: color('heading');
      color: color('surface');
    }

    &:has(:focus-visible) > span {
      outline: 2px solid color('focus-ring');
      outline-offset: 2px;
    }
  }

  &__footer {
    background-color: color('background');
    border-block-start: config('border-width-base', $display) solid color('border');
    inset-block-end: 0;
    padding-block: spacer('s');
    position: sticky;
  }
}

[dir='rtl'] .product-filters__chip {
  padding: 0.25em 0.75em 0.25em 0.5em;
}
<form class="product-filters" action="#" method="get" data-product-filters>
    <header class="product-filters__header">
        <h2 class="product-filters__title">Filters</h2>
        <a class="product-filters__clear" href="?" data-filter-clear>Clear all</a>
    </header>
    <ul class="product-filters__active" role="list" aria-label="Active filters" data-filter-active hidden></ul>
    <details class="product-filters__group" open>
        <summary>Category</summary>
        <fieldset>
            <legend class="sr-only">Category</legend>
            <div class="product-filters__checks">
                    <label class="form-check product-filters__check">
                        <input class="form-check__control" type="checkbox" name="category" value="backpacks" data-filter-label="Backpacks" data-count="32" checked>
                        <span class="form-label form-check__label">Backpacks</span>
                        <span class="product-filters__count">32<span class="sr-only"> products</span></span>
                    </label>
                    <label class="form-check product-filters__check">
                        <input class="form-check__control" type="checkbox" name="category" value="travel-bags" data-filter-label="Travel bags" data-count="18">
                        <span class="form-label form-check__label">Travel bags</span>
                        <span class="product-filters__count">18<span class="sr-only"> products</span></span>
                    </label>
                    <label class="form-check product-filters__check">
                        <input class="form-check__control" type="checkbox" name="category" value="bottles" data-filter-label="Bottles" data-count="14">
                        <span class="form-label form-check__label">Bottles</span>
                        <span class="product-filters__count">14<span class="sr-only"> products</span></span>
                    </label>
                    <label class="form-check product-filters__check">
                        <input class="form-check__control" type="checkbox" name="category" value="accessories" data-filter-label="Accessories" data-count="22">
                        <span class="form-label form-check__label">Accessories</span>
                        <span class="product-filters__count">22<span class="sr-only"> products</span></span>
                    </label>
            </div>
        </fieldset>
    </details>
    <details class="product-filters__group" open>
        <summary>Price</summary>
        <fieldset class="product-filters__price" data-price-range style="--min: 0; --max: 400;">
            <legend class="sr-only">Price range, in US dollars</legend>
            <div class="product-filters__range" aria-hidden="true" hidden>
                <input type="range" min="0" max="400" step="10" value="20" tabindex="-1" data-range-min>
                <input type="range" min="0" max="400" step="10" value="250" tabindex="-1" data-range-max>
            </div>
            <div class="product-filters__price-inputs">
                <div class="form-group">
                    <label class="form-label" for="filter-price-min">Min</label>
                    <div class="form-group--stacked">
                        <span class="form-group-label" aria-hidden="true">$</span>
                        <input class="form-control" id="filter-price-min" name="price_min" type="number" min="0" max="400" step="10" value="20" inputmode="numeric" data-input-min>
                    </div>
                </div>
                <div class="form-group">
                    <label class="form-label" for="filter-price-max">Max</label>
                    <div class="form-group--stacked">
                        <span class="form-group-label" aria-hidden="true">$</span>
                        <input class="form-control" id="filter-price-max" name="price_max" type="number" min="0" max="400" step="10" value="250" inputmode="numeric" data-input-max>
                    </div>
                </div>
            </div>
        </fieldset>
    </details>
    <details class="product-filters__group" open>
        <summary>Color</summary>
        <fieldset>
            <legend class="sr-only">Color</legend>
            <div class="product-filters__colors">
                    <label class="product-filters__color" style="--swatch: hsl(0deg 0% 12%);">
                        <input type="checkbox" name="color" value="black" data-filter-label="Black">
                        <span class="sr-only">Black</span>
                    </label>
                    <label class="product-filters__color" style="--swatch: hsl(150deg 30% 32%);">
                        <input type="checkbox" name="color" value="green" data-filter-label="Green" checked>
                        <span class="sr-only">Green</span>
                    </label>
                    <label class="product-filters__color" style="--swatch: hsl(25deg 55% 38%);">
                        <input type="checkbox" name="color" value="brown" data-filter-label="Brown">
                        <span class="sr-only">Brown</span>
                    </label>
                    <label class="product-filters__color" style="--swatch: hsl(215deg 50% 40%);">
                        <input type="checkbox" name="color" value="blue" data-filter-label="Blue">
                        <span class="sr-only">Blue</span>
                    </label>
                    <label class="product-filters__color" style="--swatch: hsl(210deg 8% 62%);">
                        <input type="checkbox" name="color" value="grey" data-filter-label="Grey">
                        <span class="sr-only">Grey</span>
                    </label>
            </div>
        </fieldset>
    </details>
    <details class="product-filters__group">
        <summary>Size</summary>
        <fieldset>
            <legend class="sr-only">Size</legend>
            <div class="product-filters__sizes">
                    <label class="product-filters__size">
                        <input type="checkbox" name="size" value="s" data-filter-label="Size S">
                        <span>S</span>
                    </label>
                    <label class="product-filters__size">
                        <input type="checkbox" name="size" value="m" data-filter-label="Size M">
                        <span>M</span>
                    </label>
                    <label class="product-filters__size">
                        <input type="checkbox" name="size" value="l" data-filter-label="Size L">
                        <span>L</span>
                    </label>
                    <label class="product-filters__size">
                        <input type="checkbox" name="size" value="xl" data-filter-label="Size XL">
                        <span>XL</span>
                    </label>
            </div>
        </fieldset>
    </details>
    <div class="product-filters__group product-filters__group--plain">
        <label class="form-switch">
            <input class="form-switch__control" type="checkbox" role="switch" name="in_stock" value="1" data-filter-label="In stock only">
            <span class="form-label form-switch__label">In stock only</span>
        </label>
    </div>
    <footer class="product-filters__footer">
        <button class="btn btn--primary btn--block" type="submit" data-filter-submit>Show results</button>
    </footer>
    <p class="sr-only" aria-live="polite" data-filter-live></p>
</form>
(() => {
  document.querySelectorAll('[data-product-filters]').forEach((form) => {
    const active = form.querySelector('[data-filter-active]');
    const submit = form.querySelector('[data-filter-submit]');
    const live = form.querySelector('[data-filter-live]');
    const price = form.querySelector('[data-price-range]');
    const rangeMin = price.querySelector('[data-range-min]');
    const rangeMax = price.querySelector('[data-range-max]');
    const inputMin = price.querySelector('[data-input-min]');
    const inputMax = price.querySelector('[data-input-max]');
    const limit = { min: Number(rangeMin.min), max: Number(rangeMin.max), step: Number(rangeMin.step) };
    const money = (value) => `$${value}`;
    let timer = null;

    // Keep min <= max and sync the sliders, the number fields and the filled track.
    const setPrice = (min, max) => {
      min = Math.max(limit.min, Math.min(Number(min), limit.max - limit.step));
      max = Math.min(limit.max, Math.max(Number(max), min + limit.step));

      rangeMin.value = inputMin.value = min;
      rangeMax.value = inputMax.value = max;
      price.style.setProperty('--from', `${((min - limit.min) / (limit.max - limit.min)) * 100}%`);
      price.style.setProperty('--to', `${((max - limit.min) / (limit.max - limit.min)) * 100}%`);
    };

    const filters = () => [...form.querySelectorAll('[data-filter-label]:checked')];

    // A rough estimate for the demo; ask your back-end for the real number.
    const estimate = () => {
      const checked = (name) => [...form.querySelectorAll(`[name="${name}"]:checked`)];
      const categories = checked('category');
      const base = categories.length
        ? categories.reduce((sum, input) => sum + Number(input.dataset.count), 0)
        : [...form.querySelectorAll('[name="category"]')].reduce((sum, input) => sum + Number(input.dataset.count), 0);
      const share = (name) => checked(name).length ? checked(name).length / form.querySelectorAll(`[name="${name}"]`).length : 1;
      const priceShare = (inputMax.value - inputMin.value) / (limit.max - limit.min);

      return Math.round(base * share('color') * share('size') * priceShare * (form.elements.in_stock.checked ? 0.8 : 1));
    };

    const chip = (label, onRemove) => {
      const item = document.createElement('li');
      const button = document.createElement('button');

      button.type = 'button';
      button.className = 'product-filters__chip';
      button.innerHTML = '<span></span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg>';
      button.firstChild.textContent = label;
      button.setAttribute('aria-label', `Remove filter: ${label}`);
      button.addEventListener('click', () => {
        const index = [...active.children].indexOf(item);

        onRemove();
        update();
        // The chips are rendered again: focus the one that took this place, or the "Clear all" link.
        const target = active.children[Math.min(index, active.children.length - 1)];
        (target?.querySelector('button') || form.querySelector('[data-filter-clear]')).focus();
      });
      item.append(button);

      return item;
    };

    const update = () => {
      const chips = filters().map((input) => chip(input.dataset.filterLabel, () => { input.checked = false; }));

      if (Number(inputMin.value) > limit.min || Number(inputMax.value) < limit.max) {
        chips.push(chip(`${money(inputMin.value)} – ${money(inputMax.value)}`, () => setPrice(limit.min, limit.max)));
      }

      active.replaceChildren(...chips);
      active.hidden = chips.length === 0;

      const count = estimate();
      submit.textContent = `Show ${count} ${count === 1 ? 'result' : 'results'}`;

      clearTimeout(timer);
      timer = setTimeout(() => { live.textContent = `${count} products match the filters.`; }, 500);
    };

    // The sliders need JavaScript; the number fields work without it.
    price.querySelector('.product-filters__range').hidden = false;

    rangeMin.addEventListener('input', () => setPrice(rangeMin.value, Math.max(rangeMax.value, Number(rangeMin.value) + limit.step)));
    rangeMax.addEventListener('input', () => setPrice(Math.min(rangeMin.value, Number(rangeMax.value) - limit.step), rangeMax.value));
    [inputMin, inputMax].forEach((input) => input.addEventListener('change', () => setPrice(inputMin.value, inputMax.value)));

    form.addEventListener('input', update);
    form.addEventListener('change', update);

    form.querySelector('[data-filter-clear]').addEventListener('click', (event) => {
      event.preventDefault();
      filters().forEach((input) => { input.checked = false; });
      setPrice(limit.min, limit.max);
      update();
    });

    setPrice(inputMin.value, inputMax.value);
    update();
  });
})();

Product Filters

Faceted filters for a product listing: categories with counts, a dual-handle price range, color swatches, sizes and an availability switch, with removable chips for the active filters.

Anatomy

  • .product-filters: a GET form, so every filter combination has a URL (?category=backpacks&color=green&price_max=250).
  • .product-filters__active: the chips of the active filters, rendered by the script.
  • .product-filters__group: a details element per facet with a fieldset inside.
  • .product-filters__price: two number fields and, with JavaScript, a dual-handle slider (__range) synced with them.
  • .product-filters__footer: the sticky submit button with the number of results.

Accessibility and Usability

  • Every facet is a fieldset with a legend, the color swatches have their names as hidden text, and the counts have hidden "products".
  • The price range: two overlapping range inputs are hard to use with assistive technology, so the number fields are the accessible controls, and the slider is a pointer shortcut that is hidden from screen readers and from the tab order (aria-hidden, tabindex="-1").
  • The chips are buttons with a clear name ("Remove filter: Green"). After a removal, the focus moves to the neighboring chip or the "Clear all" link.
  • The result count is announced in a polite live region after a short delay, so quick changes don't flood the screen reader.
  • The groups are native details, so long filter lists can be collapsed.

Technical Details

  • Progressive enhancement: without JavaScript, it's a normal form with checkboxes and number fields that submits to the listing.
  • The result number in the demo is an estimate; request the real count from your back-end (debounced) and update the button and the live region with it.
  • The two range inputs ignore the pointer (pointer-events: none) except on their thumbs, so both handles stay draggable. The filled part of the track is drawn with the --from and --to custom properties.
  • Put the form into a Drawer on small screens, and use it with the Listing Toolbar.

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.