Blog

Accessible Product Filters & Faceted Navigation: The Code Guide

TestParty
TestParty
October 15, 2026

Last updated: October 15, 2026

Accessible product filters are built from real form controls β€” a `<fieldset>` and `<legend>` per facet group, native `<input type="checkbox">` elements instead of styled divs, an `aria-live` region that announces the new result count, and focus that survives every re-render. This guide covers the failure patterns, then working code for facet groups, collapsible filters, live results, swatches, price ranges, and mobile drawers.

Key numbers: WCAG 2.2's 4.1.2 Name, Role, Value governs whether a custom checkbox or disclosure button announces correctly, and 55 of its 86 in-force criteria apply at Levels A and AA (W3C). 95.9% of the top one million home pages carried a detectable WCAG failure in 2026, averaging 56.1 errors per page (WebAIM Million, 2026). As of August 2026, TestParty has remediated 35 million-plus accessibility issues across 100+ brands, and collection-page filters rank among the most common sources of new violations in TestParty's Shopify audits (TestParty internal data). Shopify's Theme Store requirements cover only 16–22% of WCAG criteria, and stock Dawn ships 30–100 detectable violations before a merchant touches a single filter (TestParty theme audits, August 2026). Detection on filters follows the usual 60–70% automated / 30% manual split β€” a scanner flags a missing name, not a missing announcement (TestParty internal data).

Why do product filters fail accessibility audits?

Most collection-page filters fail because themes and apps build them from styled `<div>`s and `<span>`s instead of real form controls, stripping the accessible name, role, keyboard support, and state a screen reader or keyboard-only shopper depends on.

Five patterns account for nearly every filter finding in TestParty's audits. Unlabeled controls fail 1.3.1 (Info and Relationships) and 4.1.2 (Name, Role, Value). Div-soup checkboxes β€” a `<div onclick="...">` standing in for `<input type="checkbox">` β€” fail 4.1.2 and 2.1.1 (Keyboard), since a div carries no role, state, or key handling unless someone writes it by hand. Silent result-count updates fail 4.1.3 (Status Messages); focus resetting to the top on re-render fails 2.4.3 (Focus Order); and mobile drawers with no dialog role, trap, or Escape handling compound all four β€” on a page that sits directly between a shopper and the product they came for.

How do you mark up filter groups correctly?

Each facet β€” color, size, brand, price β€” is a named group of options and belongs inside a `<fieldset>` with a `<legend>`, built from real checkbox inputs tied to labels, never a styled div standing in for a checkbox.

<!-- Before: fails 1.3.1 (Info and Relationships) and 4.1.2 (Name, Role, Value) β€” no group
     name, and the "checkbox" is a div with no accessible name, role, or state -->
<div class="filter-group">
  <div class="filter-group__title">Size</div>
  <div class="filter-option" onclick="toggleFilter('size', 'medium')">
    <div class="fake-checkbox"></div>
    <span>Medium (24)</span>
  </div>
</div>

<!-- After: fixes 1.3.1 and 4.1.2 β€” legend names the group; the real checkbox carries
     name, role, and state for free -->
<fieldset class="filter-group">
  <legend class="filter-group__title">Size</legend>
  <div class="filter-option">
    <input type="checkbox" id="size-medium" name="size" value="medium"
           onchange="applyFilter('size', 'medium', this.checked, this)">
    <label for="size-medium">Medium <span class="visually-hidden">, 24 products</span></label>
  </div>
</fieldset>

The count badge β€” "24" β€” belongs inside the `<label>`, either as visible text or, if the design hides it, as a `visually-hidden` span so it still reaches the accessible name. `<fieldset>`/`<legend>` satisfies 1.3.1 with zero ARIA. For the broader pattern of a real element over an ARIA-patched one, see ARIA labels for ecommerce.

How do you build accessible collapsible facets?

Collapsible facets need the W3C's disclosure pattern β€” a real button with `aria-expanded` and `aria-controls`, wrapped in a heading β€” so the expanded state announces correctly and the heading structure a screen reader navigates by stays intact.

<!-- Before: fails WCAG 4.1.2 β€” a clickable div toggles a panel with no exposed state,
     and no programmatic relationship between the control and what it controls -->
<div class="facet-header" onclick="toggleFacet('brand')">Brand <span class="chevron"></span></div>
<div class="facet-panel" id="brand-panel" style="display:none"><!-- checkboxes --></div>

<!-- After: fixes 4.1.2 and preserves heading structure for 2.4.6 (Headings and Labels) β€”
     a real button announces its own state and points to the panel it controls -->
<h3 class="facet-header">
  <button type="button" class="facet-toggle" aria-expanded="false" aria-controls="brand-panel"
          onclick="toggleFacet(this)">
    Brand
  </button>
</h3>
<div class="facet-panel" id="brand-panel" hidden><!-- fieldset/legend checkboxes go here --></div>
<script>
function toggleFacet(button) {
  const expanded = button.getAttribute('aria-expanded') === 'true';
  button.setAttribute('aria-expanded', String(!expanded)); // fixes 4.1.2 β€” state is announced
  document.getElementById(button.getAttribute('aria-controls')).hidden = expanded;
}
</script>

Keep the `<h3>` even when it's styled to look like a plain row β€” screen readers jump between headings to skim a page, and ten facets with no headings are ten identical, indistinguishable stops. Full pattern details, including a panel open by default, live in the W3C APG's disclosure pattern.

How should filter results be announced?

Filter results should update through a persistent `aria-live="polite"` region announcing the new count, while focus stays on the checkbox just used and the URL updates via `history.pushState` so the back button restores the same view.

<!-- Before: fails WCAG 4.1.3 (Status Messages) and 2.4.3 (Focus Order) β€” the count updates
     silently, and re-rendering the grid typically resets focus to the top of the page -->
<div id="results-count">128 products</div>

<!-- After: fixes 4.1.3 and 2.4.3 β€” a live region outside the re-rendered grid announces the
     new count, focus never leaves the control that triggered the change, and the URL persists -->
<div id="results-announcer" aria-live="polite" class="visually-hidden"></div>
<script>
function applyFilter(group, value, checked, checkboxEl) {
  fetchFilteredResults(group, value, checked).then(({ count, html, params }) => {
    document.getElementById('product-grid').innerHTML = html;
    document.getElementById('results-announcer').textContent = `${count} products`; // fixes 4.1.3
    checkboxEl.focus(); // fixes 2.4.3 β€” focus stays put; don't call scrollTo(0,0) here
    history.pushState({}, '', `?${params}`); // preserves filter state for the back button
  });
}
</script>

Two details break this more than any other filter bug: the live region must already exist in the DOM before its text changes, or the announcement is silently dropped, and it must sit outside the element you're replacing with `innerHTML`, or you erase the region meant to announce it (MDN's aria-live reference covers the timing).

How do you make color swatches and price ranges accessible?

Color swatches need an accessible name, not just a background-color swatch, since WCAG 1.4.1 forbids color as the only way to identify an option; price ranges need two real number inputs or a fully keyboard-operable slider, never a drag-only div.

<!-- Before: fails 1.4.1 (Use of Color) and 4.1.2 β€” color is the only identifier, so a screen
     reader announces "checkbox, not checked" with no color name attached at all -->
<div class="swatch-filter">
  <div class="swatch swatch--forest-green" onclick="toggleColor('forest-green')"></div>
  <div class="swatch swatch--rust" onclick="toggleColor('rust')"></div>
</div>

<!-- After: fixes 1.4.1 and 4.1.2 β€” accessible name carries the color's real name, independent
     of whether the hue renders or is perceivable at all -->
<fieldset class="swatch-filter">
  <legend>Color</legend>
  <label class="swatch swatch--forest-green">
    <input type="checkbox" name="color" value="forest-green"
           onchange="applyFilter('color', 'forest-green', this.checked, this)">
    <span class="visually-hidden">Forest green, 14 products</span>
  </label>
  <label class="swatch swatch--rust">
    <input type="checkbox" name="color" value="rust"
           onchange="applyFilter('color', 'rust', this.checked, this)">
    <span class="visually-hidden">Rust, 9 products</span>
  </label>
</fieldset>

Price ranges need the same treatment: two real number inputs, or a slider built right, never a drag-only div.

<!-- Two real number inputs: fixes 4.1.2 and 2.1.1 (Keyboard) β€” Tab, arrow keys, and typing
     a value all come free with the native input, no ARIA required -->
<fieldset class="price-range">
  <legend>Price</legend>
  <label for="price-min">Min price</label>
  <input type="number" id="price-min" name="price-min" min="0" max="500" value="0"
         inputmode="numeric" onchange="applyPriceFilter()">
  <label for="price-max">Max price</label>
  <input type="number" id="price-max" name="price-max" min="0" max="500" value="500"
         inputmode="numeric" onchange="applyPriceFilter()">
</fieldset>

If the design calls for a visual slider, build it on two `<input type="range">` elements or the APG's full slider pattern β€” `role="slider"` plus `aria-valuemin`/`max`/`now` and arrow-key handling β€” not a div a pointer can drag but a keyboard can't. The same WCAG 2.2 guidance on color applies if the track shows range only as a colored bar.

How do you fix mobile filter drawers?

A mobile filter drawer is a dialog: `role="dialog"` and a name, a focus trap keeping Tab inside it, Escape to close, focus returned to the button that opened it, and body scroll locked while it's open.

<!-- Before: fails 4.1.2, 2.4.3, 2.1.2 β€” no role, no focus trap, no Escape, background scrolls -->
<div class="filter-drawer" id="filter-drawer">
  <button onclick="closeDrawer()">Γ—</button>
  <!-- facets -->
</div>

<!-- After: fixes 4.1.2, 2.4.3, 2.1.2 β€” dialog role, focus trap, Escape, returned focus, locked scroll -->
<div class="filter-drawer" role="dialog" aria-modal="true" aria-labelledby="filter-drawer-title"
     id="filter-drawer" hidden>
  <h2 id="filter-drawer-title">Filter</h2>
  <button type="button" aria-label="Close filters" onclick="closeDrawer()">Γ—</button>
  <!-- fieldset/legend facets go here -->
</div>
<script>
let lastFocused;
function openDrawer(trigger) {
  lastFocused = trigger;
  const drawer = document.getElementById('filter-drawer');
  drawer.hidden = false;
  document.body.style.overflow = 'hidden'; // locks background scroll
  drawer.querySelector('button').focus();
  drawer.addEventListener('keydown', trapFocus);
}
function closeDrawer() {
  const drawer = document.getElementById('filter-drawer');
  drawer.hidden = true;
  document.body.style.overflow = '';
  drawer.removeEventListener('keydown', trapFocus);
  lastFocused.focus(); // returns focus to the trigger
}
function trapFocus(e) {
  if (e.key === 'Escape') return closeDrawer(); // fixes 2.1.2 β€” Escape always exits
  if (e.key !== 'Tab') return;
  const focusable = this.querySelectorAll('button, [href], input, select, [tabindex]:not([tabindex="-1"])');
  const first = focusable[0], last = focusable[focusable.length - 1];
  if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
  else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
</script>

`aria-modal="true"` marks the page inert but doesn't stop Tab from leaving the drawer, which is why `trapFocus` still has to exist. Check the Apply and Clear buttons last β€” they're easy to miss if they sit in a footer added after this markup was written.

How does this work in Shopify themes?

Shopify's Search & Discovery app computes which filters and values exist for a collection, but the theme's `facets.liquid` snippet decides how that data becomes HTML β€” and that's where Dawn and premium themes lose the accessibility the app's data would otherwise support.

The app exposes each filter as a `collection.filters` entry β€” a `label` plus a list of `values`, each with its own `label`, `count`, `active` state, and `param_name`. The bug lives in how the theme renders that data, not in the data itself.

{% comment %} Before: fails 1.3.1 and 4.1.2 β€” no group name, checkboxes read as unrelated {% endcomment %}
<div class="filter-block">
  <div class="filter-block__label">{{ filter.label }}</div>
  {% for value in filter.values %}
    <label>
      <input type="checkbox" name="{{ value.param_name }}" value="{{ value.value }}"
             {% if value.active %}checked{% endif %}>
      {{ value.label }} ({{ value.count }})
    </label>
  {% endfor %}
</div>

{% comment %} After: fixes 1.3.1 and 4.1.2 β€” legend names the facet; value.label's count
   becomes part of every checkbox's accessible name for free {% endcomment %}
<fieldset class="filter-block">
  <legend>{{ filter.label }}</legend>
  {% for value in filter.values %}
    <label>
      <input type="checkbox" name="{{ value.param_name }}" value="{{ value.value }}"
             {% if value.active %}checked{% endif %} onchange="this.form.requestSubmit()">
      {{ value.label }} ({{ value.count }})
    </label>
  {% endfor %}
</fieldset>

Both blocks loop inside `{% for filter in collection.filters %}`, omitted above for brevity.

Three spots break most often: `facets.liquid` itself, usually shipped without `fieldset`/`legend`; the result-count region, rarely wired to a live region; and the mobile "Filter" drawer, often a plain div with none of the dialog behavior above. None require touching the Search & Discovery app β€” they're theme overrides, the kind of fix that belongs in source-code remediation rather than a script layered on top. See what a Shopify accessibility audit covers, Shopify app accessibility issues, and the data on the most common WCAG violations for how filter bugs compare.

Frequently Asked Questions

Do accessible filters have to look visually different from a custom-styled UI? No. Every fix above styles the real `<input>` or `<button>` with CSS β€” a visually hidden native checkbox with a custom swatch drawn in its place is fine, as long as the underlying element stays a real, focusable form control.

Is `role="checkbox"` on a `<div>` an acceptable substitute for a real checkbox input? Technically valid ARIA, but it means writing focus handling, `aria-checked` toggling, and Space-key activation yourself β€” and most hand-rolled versions TestParty audits get one wrong. A native `<input type="checkbox">` gets all of it free.

Does the Shopify Search & Discovery app make my filters accessible automatically? No. The app computes which filters and values exist for a collection; the theme's `facets.liquid` snippet still controls the HTML those values render as. The underlying data can be perfectly structured while the markup wrapped around it stays div-soup, with no fieldset, legend, or real checkbox in sight.

How do I test filters for accessibility before shipping a change? Run an automated scan, then a keyboard-only pass β€” Tab to every facet, expand a group, apply a filter, confirm focus never jumps β€” then a screen reader pass to confirm the count is announced. Automated tools catch missing names but can't judge whether an announcement fires.

If I can only fix one thing this sprint, what should it be? Replace styled divs with real checkbox inputs inside a `<fieldset>`/`<legend>`. That single markup change clears WCAG 1.3.1, 4.1.2, and 2.1.1 simultaneously, restores keyboard support for free, and needs zero changes to your filtering logic, API calls, or styling β€” the highest-yield fix in this guide.

Humans + AI = this article. TestParty uses a cyborg approach to content β€” combining human accessibility expertise with AI capabilities to produce accurate, comprehensive guides. This content is for educational purposes and reflects our analysis of publicly available information as of the publication date. TestParty competes in the digital accessibility market, and we encourage readers to evaluate all solutions independently based on their specific needs.

Stay informed

Accessibility insights delivered
straight to your inbox.

Contact Us

Automate the software work for accessibility compliance, end-to-end.

Empowering businesses with seamless digital accessibility solutionsβ€”simple, inclusive, effective.

Book a Demo