Skip to content
Contact me

All building blocks

Sandbox

The filters

With filters, the number of results often changes on screen without the screen reader saying so. Here, each label gives that number before you choose, and a status area says it again afterwards.

Built with
Radio buttons and :has()
Browsers
Chrome 105+, Firefox 121+, Safari 15.4+
JavaScript
13 lines, for the counter

Demo

A recipe can have several categories: the quiche is both a starter and a main. Check Mains: the list and the counter drop to 2 recipes.

Category
  • Starters Pumpkin soup
  • StartersMains Leek quiche
  • Mains Potato gratin
  • Desserts Apple tart
  • Desserts Chocolate mousse

Anatomy

  1. fieldset Groups the filters.
  2. legend Names the group: "Category".
  3. label and input type="radio" A filter, with its number of results in the label.
  4. p role="status" The counter of displayed recipes, read again by the screen reader after each choice. Hidden with hidden until the script has run.
  5. span.visually-hidden The chosen category, for screen readers only. The checked filter already shows it on screen.
  6. ul The filtered list.
  7. li data-categories A result, with its categories separated by spaces. [data-categories~="main"] finds one of them.

HTML code

HTML
<div class="filters">
  <!-- search: a search landmark around the filters -->
  <search>
    <!-- Filters: a group of radio buttons,
         the number of recipes in each label -->
    <fieldset>
      <legend>Category</legend>
      <label>
        <input
          type="radio"
          name="category"
          value="all"
          checked>
        <span>All</span> (5)
      </label>
      <label>
        <input
          type="radio"
          name="category"
          value="starter">
        <span>Starters</span> (2)
      </label>
      <label>
        <input
          type="radio"
          name="category"
          value="main">
        <span>Mains</span> (2)
      </label>
      <label>
        <input
          type="radio"
          name="category"
          value="dessert">
        <span>Desserts</span> (2)
      </label>
    </fieldset>
  </search>
  <!-- Status area: hidden until the script has run -->
  <p
    class="filters__total"
    role="status"
    hidden>
    <span>5 recipes</span>
    <span class="visually-hidden">, all categories</span>
  </p>
  <!-- Results: each recipe lists its categories,
       separated by spaces -->
  <ul>
    <li data-categories="starter">…</li>
    <li data-categories="starter main">…</li>
    <li data-categories="main">…</li>
    <li data-categories="dessert">…</li>
    <li data-categories="dessert">…</li>
  </ul>
</div>

<script>
  // Counter: rewritten on each choice,
  // the screen reader reads it after the filter
  const filters = document.querySelector('.filters');
  const total = filters.querySelector('[role="status"]');
  const [count, category] = total.children;
  total.hidden = false;
  filters.addEventListener('change', (e) => {
    const value = e.target.value;
    const selector = value === 'all' ? 'li' : `li[data-categories~="${value}"]`;
    const n = filters.querySelectorAll(selector).length;
    count.textContent = n === 1 ? '1 recipe' : `${n} recipes`;
    category.textContent = value === 'all'
      ? ', all categories'
      : `, category: ${e.target.nextElementSibling.textContent}`;
  });
</script>
CSS
/* One rule per category.
   ~= finds a word in a space-separated list */
.filters:has(input[value="starter"]:checked)
  li:not([data-categories~="starter"]),
.filters:has(input[value="main"]:checked)
  li:not([data-categories~="main"]),
.filters:has(input[value="dessert"]:checked)
  li:not([data-categories~="dessert"]) {
  display: none;
}

/* Text read by screen readers, absent from the screen (sr-only) */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

Usage

Use it for

  • A single active filter at a time, with options that fit on screen. That is how the filters on my blog work.
  • A list already loaded in the page, filtered by the browser.

Avoid it for

  • Showing the active filter with color alone.
  • A counter that changes on screen without role="status". Nobody announces it to the screen reader.
  • A counter displayed when the script does not run: it stays stuck on the total while the list changes.
  • Hiding the radio buttons without moving the visible focus to their label.
  • Reloading the page as soon as an option is checked, with no button to apply: focus goes back to the very top.
  • A long or paginated list, filtered in the browser. Filter it on the server instead, with a form using GET and a "Filter" button, and give the new total in the page title, which the screen reader reads on load.

Accessibility

ARIA roles and attributes

The group, the names and the states come from HTML. A single role is added, status, on the counter.

AttributeElementDescription
search role (implicit)searchCreates a search landmark around the filters: the screen reader lists it with the other landmarks on the page.
group role (implicit)fieldsetNamed by its legend, "Category", which the screen reader announces when entering the filters.
radio role (implicit)input type="radio"Name given by the label, number included: "Mains (2)". The browser also exposes the checked state.
name="category"input type="radio"Ties the 4 radio buttons into a group: only one stays checked, and the screen reader announces the position, "3 of 4".
checkedinput value="all""All" is checked on load: Tab enters the group on this filter, announced as "selected".
role="status"pBrings aria-live="polite" and aria-atomic="true". The screen reader waits for the filter announcement to end, then reads the whole counter.
hiddenpWithout JavaScript, the screen reader gets no counter rather than a wrong number. The script removes the attribute on load.
class="visually-hidden"spanRead with the counter, absent from the screen. It changes the text on each choice, even when 2 filters keep the same number of recipes (Starters and Mains: 2).
display: noneliRemoves the hidden results from the accessibility tree: after choosing Mains, the screen reader announces "list, 2 items".

Keyboard navigation

KeyAction
TabEnters the group, on the checked filter
ArrowsMove to the next or previous filter and check it, in a loop
SpaceChecks the focused filter, if it is not checked yet
Tab from the groupLeaves the filters for the rest of the page

Tab order

  1. The checked filter. The other filters are reached with the arrow keys, with no extra tab stop.

With a screen reader

Example in Chrome with VoiceOver in English. The wording changes from one screen reader to another.

ActionAnnouncement
Tab to the filters"Category, group", then "All (5), selected, radio button, 1 of 4"
Right arrow twice"Mains (2), selected, radio button, 3 of 4", then "2 recipes, category: Mains"
Next item (VO + →) up to the list"list, 2 items"

Related RGAA criteria

The RGAA is the French accessibility standard. Its 106 criteria test WCAG 2.1 level AA.

Criterion 3.1: On each web page, information must not be conveyed by color alone. Is this rule met?
The active filter can be spotted without color, by the dot of the radio button.
Criterion 7.5: On each web page, are status messages correctly rendered by assistive technologies?
The number of displayed recipes is a status message, conveyed with role="status".
Criterion 10.8: On each web page, are hidden contents meant to be ignored by assistive technologies?
Hidden results are ignored by assistive technologies.
Criterion 11.5: In each form, are fields of the same kind grouped together, where needed?
Filters of the same kind are grouped in a fieldset.
Criterion 11.6: In each form, does each group of fields of the same kind have a legend?
The group has a legend, "Category".
Criterion 11.7: In each form, is each legend of a group of fields of the same kind relevant?
This legend says what the filters apply to.

Other references

Beyond the APG

The APG has no filter pattern. These new HTML and CSS features serve the demo.

New featureWhat it addsBrowsersIn the demo
searchCreates a search landmark around the filters, which the screen reader lists with the other landmarks.Chrome 118+, Firefox 118+, Safari 17+Yes
:has()Filters the list in CSS, based on the checked radio button, with no script.Chrome 105+, Firefox 121+, Safari 15.4+Yes

Variations

Multiple choice

Checkboxes to combine several categories. One rule hides every result as soon as a box is checked, another shows the results of the checked categories again. The script then counts the displayed recipes, and the part for screen readers names the checked boxes.

Categories
  • Starters Pumpkin soup
  • StartersMains Leek quiche
  • Mains Potato gratin
  • Desserts Apple tart
  • Desserts Chocolate mousse
Code
<!-- HTML -->
<label>
  <input
    type="checkbox"
    name="category"
    value="main">
  <span>Mains</span> (2)
</label>

/* CSS */
/* A checked box hides everything… */
.filters:has(:checked) li {
  display: none;
}

/* … except the recipes of the checked categories */
.filters:has([value="main"]:checked) li[data-categories~="main"] {
  display: block;
}

<script>
  // Counter: the displayed recipes,
  // and the checked boxes for screen readers
  filters.addEventListener('change', () => {
    const checked = [...filters.querySelectorAll(':checked')];
    const names = checked.map((box) => box.nextElementSibling.textContent);
    const n = [...filters.querySelectorAll('li')]
      .filter((li) => getComputedStyle(li).display !== 'none').length;
    count.textContent = n === 1 ? '1 recipe' : `${n} recipes`;
    category.textContent = checked.length
      ? `, ${names.join(' and ')}`
      : ', all categories';
  });
</script>

Filters as tags

The same HTML, with the radio button visually hidden and the label styled as a tag. A check mark in front of the active tag replaces the radio dot, and label:has(:focus-visible) keeps the focus visible.

Category
  • Starters Pumpkin soup
  • StartersMains Leek quiche
  • Mains Potato gratin
  • Desserts Apple tart
  • Desserts Chocolate mousse
Code
.filters input {
  position: absolute;
  opacity: 0;
}

.filters label:has(:checked) {
  background: var(--accent);
  color: var(--on-accent);
}

/* A check mark replaces the radio dot, outside the accessible name.
   The 1st declaration is a fallback for browsers
   that ignore the alternative text. */
.filters label:has(:checked)::before {
  content: "✓";
  content: "✓" / "";
  margin-right: 6px;
}

.filters label:has(:focus-visible) {
  outline: 3px solid var(--highlight);
}

Counter for screen readers only

When the design has no room for the counter, the .visually-hidden class goes on the whole p role="status". I keep the counter visible whenever I can: the number helps people who see the screen too.

Category
  • Starters Pumpkin soup
  • StartersMains Leek quiche
  • Mains Potato gratin
  • Desserts Apple tart
  • Desserts Chocolate mousse

The counter is still there: only the screen reader reads it.

Code
<p
  class="filters__total visually-hidden"
  role="status"
  hidden>
  <span>5 recipes</span><span>, all categories</span>
</p>