Implementing Accessible Product Filters
AI generated
A11Y
WCAG
Accessibility · WCAG 2.2 · ARIA · Layered Navigation
Implementing Accessible Product Filters (Layered Navigation)
Keyboard, live regions, and focus management for store filters

Product filters that only work with a mouse, or that drop focus after every selection, effectively lock out keyboard and screen reader users. This article shows how to mark up checkboxes, radio buttons, and price sliders correctly in a layered navigation, make them keyboard operable, and combine them with live regions for updated result counts, all without losing focus when the product grid reloads.

14 min. read WCAG 2.2 AA · ARIA · Focus Management Hyva Theme · Alpine.js · Magento 2

1. Why accessible product filters decide conversion and legal compliance

Product filters are the central control in almost every online store, the difference between hundreds of products and the handful that actually fit. When that control only works with a mouse, it effectively excludes keyboard users, screen reader users, and people with motor impairments from product discovery. That is not just a legal risk under the German Accessibility Strengthening Act (BFSG), which has applied to B2C online stores since June 2025, it is also a hard conversion problem: a customer who cannot operate a filter leaves the site instead of buying.

In practice, product filters fail at the same recurring spots: checkboxes without a visible label, custom dropdowns without keyboard support, result counts that change unnoticed, and a product grid that throws focus back to the page body after every click. Every one of these mistakes can be fixed with established WAI-ARIA patterns and a few lines of semantic HTML, as the following sections show using a layered navigation for Magento and Hyva as the example.

2. Semantic structure: fieldset, legend, and label for filter groups

Each filter group, such as color or size, belongs inside a native <fieldset> element with a <legend> as its group title. Screen readers automatically announce the legend as soon as focus reaches the first checkbox in the group, so users know the context of the option without hearing every single label repeated. A plain <div> with a visually bold heading does not provide that association, because the grouping only exists visually, not programmatically.

Every single checkbox needs a <label> bound to the checkbox's id via the for attribute, never just a surrounding <span> styled with CSS. The visible text should include the match count, for example Red (12), so screen reader users know how many products to expect before selecting an option. aria-describedby can add supplementary hints such as multiple selection allowed without lengthening the primary label text.


<!-- Filter group: fieldset + legend, each checkbox with a bound label -->
<fieldset class="border-0 p-0 m-0 mb-6">
  <legend class="font-bold text-slate-800 text-sm mb-3">Color</legend>
  <ul class="space-y-2 list-none m-0 p-0">
    <li>
      <input
        type="checkbox"
        id="filter-color-red"
        name="color[]"
        value="red"
        class="w-4 h-4"
        aria-describedby="filter-color-red-hint"
      >
      <label for="filter-color-red" class="ml-2 text-sm text-slate-700">Red (12)</label>
      <span id="filter-color-red-hint" class="sr-only">Multiple selection allowed</span>
    </li>
    <li>
      <input type="checkbox" id="filter-color-blue" name="color[]" value="blue" class="w-4 h-4">
      <label for="filter-color-blue" class="ml-2 text-sm text-slate-700">Blue (7)</label>
    </li>
  </ul>
</fieldset>

3. Keyboard operability: checkboxes, radio buttons, and focus visibility

Checkboxes and radio buttons are reachable with the Tab key and operable with the Space bar or arrow keys by default, as long as they remain native HTML elements and are not replaced with <div> or <span> constructs driven by click handlers. The problem usually appears once designers hide the native checkbox completely (display: none) and replace it with a purely visual element that no longer has any keyboard interaction at all.

The safe approach is to keep the native checkbox visually hidden but focusable with an sr-only-style class, and drive the visual replacement element through the :checked and :focus-visible selectors. That way the checkbox retains its full keyboard and screen reader logic while the design remains freely stylable. A clearly visible focus ring with sufficient contrast, at least 3:1 per WCAG 2.2 criterion 2.4.11, is mandatory, especially since many store themes remove the browser's default focus ring via CSS without replacing it.


/* Real checkbox stays in the DOM and keyboard-focusable, only visually hidden */
.filter-checkbox {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Custom visual box driven by the real checkbox state */
.filter-checkbox + .filter-checkbox-box {
  width: 1.25rem;
  height: 1.25rem;
  border: 2px solid #71717a;
  border-radius: 0.25rem;
}

.filter-checkbox:checked + .filter-checkbox-box {
  background-color: #18181b;
  border-color: #18181b;
}

/* Visible, high-contrast focus ring, only on keyboard focus */
.filter-checkbox:focus-visible + .filter-checkbox-box {
  outline: 3px solid #3f3f46;
  outline-offset: 2px;
}

4. Live regions: announcing the updated result count after filtering

When a filter is toggled and the product grid updates via AJAX, the result count changes visually right away, but without aria-live that change stays invisible to screen reader users. The fix is a dedicated live region, an element with aria-live="polite" and role="status", containing only the current match count, updated via JavaScript on every filter change. Polite means the announcement waits until the screen reader finishes its current speech output instead of interrupting it.

It matters to leave the live region empty in the DOM on the initial page load and only set its text after the first user interaction, otherwise the screen reader announces the match count while the page is still loading, before any filter was actually applied. The region should not be visually hidden entirely, instead the regular, visible result-count display itself should be marked as the live region, so sighted and blind users receive the same information at the same time.


<!-- Visible result count doubles as the live region, no separate hidden element -->
<p
  id="filter-result-count"
  role="status"
  aria-live="polite"
  aria-atomic="true"
  class="text-sm font-semibold text-slate-700 mb-4"
>
  128 products
</p>

<script>
function updateResultCount(newCount) {
  const region = document.getElementById('filter-result-count');
  // aria-atomic ensures the full sentence is re-announced, not just the diff
  region.textContent = newCount + ' products found';
}
</script>

5. Focus management: no lost focus when the product grid re-renders

Probably the single biggest real-world UX mistake with AJAX filters: after every click, the entire product grid container is replaced via innerHTML, which removes the previously focused checkbox node from the DOM. The browser then automatically throws focus back to the <body> element, and keyboard users have to tab through the whole page from the top again just to reach the next filter. For screen reader users, it feels as if the entire page reloaded.

The correct pattern leaves the filter container that owns focus untouched and only replaces the product grid area. In addition, the script remembers which element was focused before the re-render and explicitly restores focus via element.focus() afterward, in case the original element did have to be re-rendered. For the case where the focused node truly gets replaced, a tabindex="-1" on a stable anchor element such as the results heading is a proven fallback for moving focus there programmatically.


// Preserve keyboard focus across an AJAX re-render of the product grid
async function applyFilter(url) {
  const activeElement = document.activeElement;
  const activeId = activeElement && activeElement.id;

  const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
  const html = await response.text();

  // Only the grid is replaced, the filter fieldset stays untouched in the DOM
  document.getElementById('product-grid').innerHTML = html;
  updateResultCount(document.querySelectorAll('.product-card').length);

  // Restore focus if the original node still exists after the swap
  const restored = activeId && document.getElementById(activeId);
  if (restored) {
    restored.focus();
    return;
  }

  // Fallback: move focus to a stable, always-present anchor heading
  const anchor = document.getElementById('filter-result-heading');
  anchor.setAttribute('tabindex', '-1');
  anchor.focus();
}

6. ARIA patterns for multiselect dropdowns and filter chips

Custom dropdowns for filters, such as a multiselect menu for brands, need the WAI-ARIA listbox or combobox pattern, not a hand-rolled div construct without roles. A button with aria-haspopup="listbox" and aria-expanded opens the list, marked up as a ul with role="listbox", each item as an li with role="option" and aria-selected. Arrow keys navigate between options, Space or Enter selects, and Escape closes the menu and returns focus to the triggering button.

Active filters shown as removable chips above the product grid each need their own button with a unique aria-label such as Remove filter Color Red, instead of a generic cross icon without a text alternative. After removing a chip, focus should not fall into empty space, it should move to the next remaining chip or, if none remain, back to the corresponding filter checkbox in the fieldset group.

7. Building an accessible price range slider

A price filter with two handles for minimum and maximum can be built with either two native input[type=range] elements or the WAI-ARIA slider pattern (role="slider"). The native option is considerably more robust, because keyboard control with arrow keys, page up, page down, home, and end works automatically, without writing custom JavaScript for every key. Two overlapping range inputs with different z-index values are the common pattern for a dual slider, with each handle carrying its own aria-label such as Minimum price or Maximum price.

If a fully custom-styled slider component is built instead, aria-valuemin, aria-valuemax, and aria-valuenow must be kept current on every movement, along with an aria-valuetext that formats the value in a readable way, for example 49 euros instead of just the raw number 49. Without aria-valuetext, some screen readers only read the raw digit, which is ambiguous for currency or percentage values. The handle's focus ring must stay visible on every keyboard input, even while the value changes in real time.

8. Practical pattern: layered navigation with Alpine.js in Hyva

In Hyva, an accessible layered navigation can be built entirely with an Alpine.js component, without loading any additional JavaScript libraries. The component holds the loading state, the current match count, and a reference to the last focused element in its local state, and updates both the product grid and the live region with the new match count after every AJAX request, without re-rendering the rest of the page.

The key trick in the example below: the x-init hook remembers the active element before the fetch call, and after inserting the new product cards, the component checks whether that element still exists in the DOM. If it does, focus stays unchanged. If it is gone, focus moves explicitly to the results-count heading, which carries a tabindex="-1" for that purpose, making it programmatically focusable without joining the normal tab order.


// Hyva_Theme::product/layered-nav.phtml - Alpine.js component
document.addEventListener('alpine:init', () => {
  Alpine.data('layeredNav', () => ({
    loading: false,
    resultCount: 0,

    async toggleFilter(event, url) {
      this.loading = true;
      const lastFocusedId = document.activeElement.id;

      try {
        const response = await fetch(url, {
          headers: { 'X-Requested-With': 'XMLHttpRequest' }
        });
        const data = await response.json();

        // Replace only the grid, the fieldset filters stay mounted
        document.getElementById('product-grid').innerHTML = data.gridHtml;
        this.resultCount = data.count;

        // Announce the new count through the existing live region
        this.$refs.liveRegion.textContent = data.count + ' products found';

        // Restore focus to the same control if it still exists
        this.$nextTick(() => {
          const restored = document.getElementById(lastFocusedId);
          if (restored) {
            restored.focus();
          } else {
            this.$refs.resultHeading.focus();
          }
        });
      } finally {
        this.loading = false;
      }
    }
  }));
});

9. Testing and comparison: screen readers, axe-core, and common mistakes

Automated tools such as axe-core or Lighthouse accessibility audits reliably find missing labels, insufficient contrast, and missing ARIA attributes, but they do not detect focus loss after an AJAX update or incorrectly phrased live region announcements. That is why every layered navigation additionally needs a manual test with a real screen reader: NVDA with Firefox on Windows or VoiceOver with Safari on macOS are the most common combinations for the European market.

During the manual test, filtering happens entirely with the keyboard, no mouse at all, checking whether every live region announcement is accurate and timely, whether focus lands in a sensible place after every interaction, and whether custom dropdowns respond as expected to arrow keys, Escape, and Enter. The following table compares typical implementation mistakes with their accessible counterpart.

Area Not accessible Accessible pattern Benefit
Filter group div with a bold heading fieldset + legend Group context announced programmatically
Checkbox replacement div with onclick, display: none checkbox Native checkbox, sr-only + :checked Keyboard and screen reader logic preserved
Result count Visual text change only role="status" aria-live="polite" Screen reader announces the new count
Grid update innerHTML of the whole container Remember focus, restore it explicitly No fallback of focus to body
Price slider Custom build without aria-valuenow input[type=range] or role="slider" + aria-valuetext Value read out correctly and clearly

Mironsoft

Accessibility audits and WCAG-compliant implementation for Magento and Hyva stores

Product filters that are truly usable for everyone?

We audit your layered navigation for keyboard operability, live regions, and focus management, then implement the necessary changes directly in your Hyva theme, so your store meets the BFSG and stays usable for every customer.

Accessibility audit

Keyboard, screen reader, and axe-core testing of your filters and forms

Hyva implementation

ARIA patterns, live regions, and focus management built directly into the theme

BFSG consulting

Understand legal requirements and get a prioritized implementation plan

10. Summary

Accessible product filters always solve the same underlying problem: users who cannot or do not want to work with a mouse must not be excluded from product discovery. Semantic HTML with fieldset and legend, native checkboxes with a visible focus ring instead of rebuilt ones, an aria-live region for the match count, and focus management that preserves the last focused node across AJAX updates together form a complete, accessible pattern for layered navigation.

The biggest lever is applying these patterns consistently to every filter component, not just the simple checkbox lists. Custom dropdowns, filter chips, and price sliders each need their own ARIA roles and keyboard handling. Automated tools such as axe-core find a large share of the obvious mistakes, but only a manual test with a real screen reader reliably shows whether live regions and focus management actually work together in practice.

Accessible product filters, the essentials at a glance

Semantic structure

fieldset + legend per filter group, every checkbox with a bound label instead of pure CSS styling.

Live region

role="status" with aria-live="polite" for the match count, updated on every filter change.

Focus management

Remember the active element before the re-render, restore it afterward, or move focus deliberately to a stable heading.

Testing

axe-core for automated baseline checks, NVDA/VoiceOver for manual testing of live regions and focus.

11. FAQ: Implementing Accessible Product Filters

1Why do product filters need to be accessible?
Otherwise they exclude keyboard and screen reader users from product discovery. Since June 2025 the BFSG requires accessible B2C stores, and unusable filters are a direct conversion blocker.
2Why isn't a div with a bold heading enough?
The grouping only exists visually. fieldset with legend is announced programmatically as focus reaches the first checkbox, a div provides no such association.
3Can I completely hide native checkboxes visually?
Only with an sr-only-style class that stays focusable. display: none removes the checkbox from the tab order and from keyboard operability.
4What does a live region do for the result count?
role="status" with aria-live="polite" is read aloud automatically once its content changes. Blind users immediately learn the new match count.
5Why polite instead of assertive?
Polite waits until the current speech output ends. Assertive would cut off any ongoing announcement immediately, unnecessarily intrusive for a result count.
6Why does the grid often lose focus?
The container is replaced via innerHTML, removing the focused node with it. The browser then throws focus back to the body element.
7How do I prevent focus loss during a grid update?
Remember the active element before the re-render, check for existence after the update, restore focus, or move it to a stable heading with tabindex="-1".
8Which ARIA pattern for multiselect dropdowns?
The listbox pattern: button with aria-haspopup="listbox", list with role="listbox", options with role="option" and aria-selected. Arrow keys navigate, Escape closes.
9How do I build an accessible price slider?
Most robustly with two native input[type=range]. For a custom build with role="slider", aria-valuemin, aria-valuemax, aria-valuenow, and aria-valuetext must be maintained.
10Is axe-core enough to test filters?
No. axe-core finds missing labels and contrast errors, but not focus loss or incorrect live region announcements. A manual test with NVDA or VoiceOver is also required.