Listing Toolbar

4 of 86 products

Filters 2 active

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

.listing-toolbar {
  @include layout-stack('m');

  &__bar {
    align-items: center;
    border-block: config('border-width-base', $display) solid color('border');
    display: flex;
    flex-wrap: wrap;
    gap: spacer('xs') spacer('s');
    padding-block: spacer('xs');
  }

  &__count {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
    margin: 0 auto 0 0;

    strong {
      color: color('heading');
    }
  }

  &__badge {
    background-color: color('primary');
    border-radius: radius('pill');
    color: color('primary-foreground', 'btn');
    font-size: 0.6875rem;
    line-height: 1;
    padding: 0.25em 0.5em;
  }

  &__sort {
    align-items: center;
    display: flex;
    gap: spacer('xs');

    .form-control {
      inline-size: auto;
    }
  }

  &__label {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
    white-space: nowrap;
  }

  &__view {
    border: config('border-width-base', $display) solid color('border');
    border-radius: radius('md');
    display: flex;
    padding: 2px;
  }

  &__view-button {
    @include clear-btn;
    border-radius: radius('sm');
    color: color('muted');
    display: inline-flex;
    padding: spacer('xxs');
    @include transition;

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

    &:hover {
      color: color('heading');
    }

    &[aria-pressed='true'] {
      background-color: color('surface-muted');
      color: color('heading');
    }

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

  // The demo listing.
  &__results {
    // The parent's layout-stack sets the margins.
    list-style: none;
    padding: 0;
    // Two columns even on phones.
    @include layout-grid('s', 9rem);

    &--list {
      display: grid;
      grid-template-columns: 1fr;

      .listing-toolbar__product {
        align-items: center;
        gap: spacer('s');
        grid-template-columns: 6rem minmax(0, 1fr);

        img {
          border-radius: radius('md');
        }
      }

      .listing-toolbar__product-text {
        display: block;
      }
    }

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

  &__product {
    align-content: start;
    display: grid;
    gap: spacer('xs');
    @include a11y-card-link('.listing-toolbar__product-title a');

    img {
      aspect-ratio: 1;
      background-color: color('surface-muted');
      border-radius: radius('lg');
      inline-size: 100%;
      object-fit: cover;
    }
  }

  &__product-body {
    @include layout-stack('xxs');
  }

  &__product-title {
    font-family: inherit;
    font-size: config('font-size-base', $typography);
    margin: 0;

    a {
      color: color('heading');
      text-decoration: none;
    }
  }

  &__product:hover &__product-title a {
    text-decoration: underline;
  }

  &__product-text {
    color: color('muted');
    display: none;
    font-size: config('font-size-sm', $typography);
  }

  &__product-meta {
    display: flex;
    font-size: config('font-size-sm', $typography);
    gap: spacer('xs');
    justify-content: space-between;

    strong {
      color: color('heading');
    }

    span {
      color: color('muted');
    }
  }
}

[dir='rtl'] .listing-toolbar__count {
  margin: 0 0 0 auto;
}
<div class="listing-toolbar" data-listing-toolbar>
    <form class="listing-toolbar__bar" action="#" method="get">
        <p class="listing-toolbar__count"><strong>4</strong> of 86 products</p>
        <a class="btn btn--outline-secondary btn--sm listing-toolbar__filters" href="#filters">
            <svg class="btn__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 6h18M7 12h10M10 18h4"/></svg>
            Filters
            <span class="listing-toolbar__badge">2<span class="sr-only"> active</span></span>
        </a>
        <div class="listing-toolbar__sort">
            <label class="listing-toolbar__label" for="listing-sort">Sort by</label>
            <select class="form-control form-control--sm" id="listing-sort" name="sort" data-listing-sort>
                <option value="featured">Featured</option>
                <option value="newest">Newest</option>
                <option value="price-asc">Price: low to high</option>
                <option value="price-desc">Price: high to low</option>
                <option value="rating">Best rated</option>
            </select>
            <button class="btn btn--outline-secondary btn--sm" type="submit" data-listing-submit>Apply</button>
        </div>
        <div class="listing-toolbar__view" role="group" aria-label="View" data-listing-view hidden>
            <button class="listing-toolbar__view-button" type="button" aria-pressed="true" data-view="grid"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg><span class="sr-only">Grid</span></button>
            <button class="listing-toolbar__view-button" type="button" aria-pressed="false" data-view="list"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg><span class="sr-only">List</span></button>
        </div>
    </form>
    <p class="sr-only" aria-live="polite" data-listing-live></p>
    <ul class="listing-toolbar__results" role="list" data-listing-results>
        <li class="listing-toolbar__product" data-price="89" data-rating="4.7" data-date="2026-09-12" data-order="1">
            <img src="https://images.unsplash.com/photo-1553062407-98eeb64c6a62?auto=format&fit=crop&w=400&h=400&q=60" alt="" width="400" height="400" loading="lazy">
            <div class="listing-toolbar__product-body">
                <h3 class="listing-toolbar__product-title"><a href="#">Everyday Backpack</a></h3>
                <p class="listing-toolbar__product-text">Charcoal canvas, 22 L, padded laptop sleeve.</p>
                <p class="listing-toolbar__product-meta"><strong>$89</strong> <span>★ 4.7<span class="sr-only"> out of 5</span></span></p>
            </div>
        </li>
        <li class="listing-toolbar__product" data-price="189" data-rating="4.6" data-date="2026-09-28" data-order="2">
            <img src="https://images.unsplash.com/photo-1577733966973-d680bffd2e80?auto=format&fit=crop&w=400&h=400&q=60" alt="" width="400" height="400" loading="lazy">
            <div class="listing-toolbar__product-body">
                <h3 class="listing-toolbar__product-title"><a href="#">Heritage Backpack</a></h3>
                <p class="listing-toolbar__product-text">Waxed cotton with leather straps.</p>
                <p class="listing-toolbar__product-meta"><strong>$189</strong> <span>★ 4.6<span class="sr-only"> out of 5</span></span></p>
            </div>
        </li>
        <li class="listing-toolbar__product" data-price="129" data-rating="4.4" data-date="2026-08-30" data-order="3">
            <img src="https://images.unsplash.com/photo-1585916420730-d7f95e942d43?auto=format&fit=crop&w=400&h=400&q=60" alt="" width="400" height="400" loading="lazy">
            <div class="listing-toolbar__product-body">
                <h3 class="listing-toolbar__product-title"><a href="#">Commuter Pack</a></h3>
                <p class="listing-toolbar__product-text">Water-resistant, roll-top, 24 L.</p>
                <p class="listing-toolbar__product-meta"><strong>$129</strong> <span>★ 4.4<span class="sr-only"> out of 5</span></span></p>
            </div>
        </li>
        <li class="listing-toolbar__product" data-price="69" data-rating="4.2" data-date="2026-10-01" data-order="4">
            <img src="https://images.unsplash.com/photo-1546938576-6e6a64f317cc?auto=format&fit=crop&w=400&h=400&q=60" alt="" width="400" height="400" loading="lazy">
            <div class="listing-toolbar__product-body">
                <h3 class="listing-toolbar__product-title"><a href="#">Canvas Daypack</a></h3>
                <p class="listing-toolbar__product-text">Lightweight everyday daypack, 16 L.</p>
                <p class="listing-toolbar__product-meta"><strong>$69</strong> <span>★ 4.2<span class="sr-only"> out of 5</span></span></p>
            </div>
        </li>
    </ul>
</div>
(() => {
  const storageKey = 'listing-view';

  document.querySelectorAll('[data-listing-toolbar]').forEach((toolbar) => {
    const sort = toolbar.querySelector('[data-listing-sort]');
    const results = toolbar.querySelector('[data-listing-results]');
    const view = toolbar.querySelector('[data-listing-view]');
    const live = toolbar.querySelector('[data-listing-live]');

    const comparators = {
      featured: (a, b) => a.dataset.order - b.dataset.order,
      newest: (a, b) => b.dataset.date.localeCompare(a.dataset.date),
      'price-asc': (a, b) => a.dataset.price - b.dataset.price,
      'price-desc': (a, b) => b.dataset.price - a.dataset.price,
      rating: (a, b) => b.dataset.rating - a.dataset.rating
    };

    const setView = (value) => {
      results.classList.toggle('listing-toolbar__results--list', value === 'list');
      view.querySelectorAll('[data-view]').forEach((button) => {
        button.setAttribute('aria-pressed', String(button.dataset.view === value));
      });

      try {
        localStorage.setItem(storageKey, value);
      } catch (error) {}
    };

    // Sort on change; without JavaScript, the "Apply" button submits the form and the server sorts.
    toolbar.querySelector('[data-listing-submit]').hidden = true;
    sort.addEventListener('change', () => {
      results.append(...[...results.children].sort(comparators[sort.value]));
      live.textContent = `Sorted by ${sort.selectedOptions[0].text}.`;
    });

    view.hidden = false;
    view.addEventListener('click', (event) => {
      const button = event.target.closest('[data-view]');

      if (button) {
        setView(button.dataset.view);
      }
    });

    try {
      setView(localStorage.getItem(storageKey) || 'grid');
    } catch (error) {
      setView('grid');
    }
  });
})();

Listing Toolbar

The toolbar above a product listing: the number of results, a filters button, the sort order and a grid/list view switch.

Anatomy

  • .listing-toolbar__bar: a GET form with the __count, the __filters link (with the active filter __badge), the __sort select and the __view buttons.
  • .listing-toolbar__results: the listing (here a demo); the --list modifier switches it to the list layout.

Accessibility and Usability

  • The view switch is a role="group" of two toggle buttons (aria-pressed), with hidden text for the icons ("Grid", "List").
  • Sorting is announced in a polite live region ("Sorted by Price: low to high"), because the change happens out of sight.
  • The filter badge has hidden text ("2 active"), so the link reads "Filters, 2 active".
  • The product cards are clickable as a whole with the a11y-card-link mixin, while the title link stays the only link for assistive technology.

Technical Details

  • Progressive enhancement: without JavaScript, the "Apply" button submits the sort (?sort=price-asc), and the server sorts. With JavaScript, the button is hidden, and the select sorts the items on the page by their data-price, data-rating, data-date and data-order attributes. With pagination, sort on the server instead (reload the page or fetch the results).
  • The chosen view is saved in localStorage (in a try block, because the storage can be unavailable) and restored on the next visit. The view buttons need JavaScript, so they're hidden without it.
  • Point the filters link to the Product Filters (or a Drawer with them on small screens).

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.