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.
5 recipes, all categories
- Starters Pumpkin soup
- StartersMains Leek quiche
- Mains Potato gratin
- Desserts Apple tart
- Desserts Chocolate mousse
Anatomy
fieldsetGroups the filters.legendNames the group: "Category".labelandinput type="radio"A filter, with its number of results in the label.p role="status"The counter of displayed recipes, read again by the screen reader after each choice. Hidden withhiddenuntil the script has run.span.visually-hiddenThe chosen category, for screen readers only. The checked filter already shows it on screen.ulThe filtered list.li data-categoriesA result, with its categories separated by spaces.[data-categories~="main"]finds one of them.
HTML code
<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>/* 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
formusingGETand 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.
| Attribute | Element | Description |
|---|---|---|
search role (implicit) | search | Creates a search landmark around the filters: the screen reader lists it with the other landmarks on the page. |
group role (implicit) | fieldset | Named 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". |
checked | input value="all" | "All" is checked on load: Tab enters the group on this filter, announced as "selected". |
role="status" | p | Brings aria-live="polite" and aria-atomic="true". The screen reader waits for the filter announcement to end, then reads the whole counter. |
hidden | p | Without JavaScript, the screen reader gets no counter rather than a wrong number. The script removes the attribute on load. |
class="visually-hidden" | span | Read 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: none | li | Removes the hidden results from the accessibility tree: after choosing Mains, the screen reader announces "list, 2 items". |
Keyboard navigation
| Key | Action |
|---|---|
| Tab | Enters the group, on the checked filter |
| Arrows | Move to the next or previous filter and check it, in a loop |
| Space | Checks the focused filter, if it is not checked yet |
| Tab from the group | Leaves the filters for the rest of the page |
Tab order
- 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.
| Action | Announcement |
|---|---|
| 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
WCAG 2.2
EN 301 549
- Accessibility requirements for ICT products and services (V4.1.1, PDF)Clauses 9.1.3.1, 9.1.4.1, 9.3.3.2, 9.4.1.3
APG pattern
Beyond the APG
The APG has no filter pattern. These new HTML and CSS features serve the demo.
| New feature | What it adds | Browsers | In the demo |
|---|---|---|---|
search | Creates 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.
5 recipes, all categories
- Starters Pumpkin soup
- StartersMains Leek quiche
- Mains Potato gratin
- Desserts Apple tart
- Desserts Chocolate mousse
<!-- 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.
5 recipes, all categories
- Starters Pumpkin soup
- StartersMains Leek quiche
- Mains Potato gratin
- Desserts Apple tart
- Desserts Chocolate mousse
.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.
5 recipes, all categories
- 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.
<p
class="filters__total visually-hidden"
role="status"
hidden>
<span>5 recipes</span><span>, all categories</span>
</p>