You do not have to uninstall your filter app to make a collection page usable. Most of the failures merchants get flagged for live in the markup the app injects and the JavaScript the theme runs after a filter is toggled. Those are patchable. This is the QA pass worth running on every collection template before you sign off on it, written for merchants who would rather fix the storefront than file another support ticket and wait.

If you arrived here from a scan report with a wall of “interactive element not focusable” or “no programmatic name” findings on /collections/all, you are in the right place. The fixes below are scoped to product filters, filter drawers, and the result list they control. For the broader picture of how third-party apps tend to break Shopify accessibility, read the Shopify third-party app WCAG violations breakdown first. For the general “do not uninstall, patch instead” pattern, see fix Shopify app accessibility errors without uninstalling.

Why collection filters fail accessibility checks so often

Collection filters look simple. A list of checkboxes, a few price sliders, maybe a swatch picker. In practice they are one of the most complex interactive surfaces on a Shopify store. The browser sees a multi-step dance that involves a fetch request, a DOM replacement, focus management, sometimes a modal dialog on mobile, sometimes a URL change, sometimes both, and usually some custom rendering on top of native controls.

The same five or six bugs show up in almost every audit. The fixes are short, repeatable, and mostly theme level.

  • Custom checkboxes built from <div> with onclick, no role, no keyboard handler.
  • Swatches that are <a> tags with no accessible name beyond a background color.
  • A mobile filter drawer that is a styled <aside>, not a real dialog, with no focus trap and no escape route.
  • A “showing 24 of 312 products” counter that updates silently after every filter toggle.
  • Clear and reset buttons that disappear from the tab order when the panel collapses.
  • Infinite scroll that swallows keyboard focus when new tiles append.

Each of these maps to one or two well-known WCAG criteria. None of them require ripping out the filter app.

The WCAG criteria that actually apply

Run your filter QA against these. They are the criteria a scanner will flag and the ones a plaintiff lawyer can build a complaint around.

  • 1.3.1 Info and Relationships (Level A). Filter groups must be programmatically associated with their labels. A “Color” heading sitting above a div full of unlabeled buttons does not count.
  • 2.1.1 Keyboard (Level A). Every filter control must be operable from the keyboard. Custom checkboxes and swatches are the usual offenders.
  • 2.4.3 Focus Order (Level A). When the filter drawer opens, focus moves into it. When it closes, focus returns to the trigger.
  • 2.4.7 Focus Visible (Level AA). The focus ring is not “ugly” and it is not optional. Restore it on swatches and pill buttons that hide it for design reasons.
  • 2.5.8 Target Size Minimum (Level AA). Tiny swatch dots and remove pills need a 24x24 pixel hit area at minimum.
  • 4.1.2 Name, Role, Value (Level A). Custom controls must expose what they are and whether they are selected.
  • 4.1.3 Status Messages (Level AA). Result counts and filter changes need an aria-live announcement so a screen reader user knows the list actually refreshed.

Most of the work is in 1.3.1, 4.1.2, and 4.1.3. Nail those three and the rest tend to follow.

What to test on desktop and mobile

Do not start with a mouse. Pick up the keyboard first. You will instinctively click past the broken parts and miss exactly what a scanner will catch.

Desktop, full width:

  1. Tab into the filter rail from the page heading. Confirm every filter group heading is reachable and labeled.
  2. Open and close each collapsible filter group with Enter and Space. Confirm the toggle has aria-expanded.
  3. Toggle a checkbox with Space. Confirm the result grid updates.
  4. Listen for a screen reader announcement of the new result count. If nothing is said, you have a 4.1.3 problem.
  5. Tab through the price range. Confirm both endpoints are reachable and have accessible names like “Minimum price” and “Maximum price.”
  6. Click the clear all filters button with the keyboard. Confirm focus does not vanish.

Mobile width or simulated narrow viewport:

  1. Tab to the filter drawer trigger. Press Enter.
  2. Confirm focus moves into the drawer and Tab cycles only inside it.
  3. Confirm Escape closes the drawer and focus returns to the trigger.
  4. Apply two filters, scroll inside the drawer, and close it. Confirm the result count and the URL both reflect the applied filters.
  5. With VoiceOver on iOS or TalkBack on Android, swipe through the drawer. Confirm every filter has a name, a role, and a state.

Save the result as a checklist. A short screen recording with screen reader output is worth more than a passing scanner report.

How to patch the common problems

You have two repair surfaces. The theme code in your Liquid templates, and a runtime script that touches markup injected by the app itself. Most of the time you want both. Theme code handles markup you control. The runtime script handles markup the app injects.

The simplest version uses a MutationObserver on the grid container and runs the patches again whenever the DOM changes.

function patchFilters(root) {
  // Custom checkboxes built from div or span
  root.querySelectorAll('[data-filter-option]:not([data-a11y-fixed])').forEach((node) => {
    const label = node.getAttribute('data-filter-label') || node.textContent.trim();
    const selected = node.getAttribute('aria-checked') === 'true'
      || node.classList.contains('is-selected');

    if (node.tagName !== 'INPUT') {
      node.setAttribute('role', 'checkbox');
      node.setAttribute('aria-checked', selected ? 'true' : 'false');
      node.setAttribute('tabindex', '0');
      node.setAttribute('aria-label', label);
    }

    node.addEventListener('keydown', (event) => {
      if (event.key === ' ' || event.key === 'Enter') {
        event.preventDefault();
        node.click();
      }
    });

    node.setAttribute('data-a11y-fixed', '1');
  });

  // Swatches without an accessible name
  root.querySelectorAll('.swatch-button:not([data-a11y-fixed])').forEach((node) => {
    if (!node.getAttribute('aria-label')) {
      const value = node.getAttribute('data-value') || node.title || 'Color option';
      node.setAttribute('aria-label', value);
    }
    node.setAttribute('data-a11y-fixed', '1');
  });
}

const grid = document.querySelector('[data-collection]');
if (grid) {
  patchFilters(grid);
  new MutationObserver(() => patchFilters(grid)).observe(grid, {
    childList: true,
    subtree: true
  });
}

This is not pretty. It is a stopgap that holds the line while you push a proper fix upstream to the app or into your theme. Ship it today and clean up next sprint.

For native theme markup, prefer real controls. A genuine checkbox with a visible label is always cheaper to maintain than a styled div pretending to be one.

<fieldset class="filter-group">
  <legend>Color</legend>
  <label class="filter-option">
    <input
      type="checkbox"
      name="filter.v.option.color"
      value="black"
      {% if current_value contains 'black' %}checked{% endif %}
    />
    <span>Black</span>
  </label>
  <label class="filter-option">
    <input
      type="checkbox"
      name="filter.v.option.color"
      value="white"
      {% if current_value contains 'white' %}checked{% endif %}
    />
    <span>White</span>
  </label>
</fieldset>

A fieldset and legend are the cleanest way to group filters under one label. Skip ARIA when the HTML already says it.

Filter drawers, the right way

The mobile filter drawer is where the worst bugs live, because the drawer must behave like a real dialog and most themes ship it as a glorified sidebar. The container should look like this.

<div
  id="FilterDrawer"
  role="dialog"
  aria-modal="true"
  aria-labelledby="FilterDrawer-Heading"
  tabindex="-1"
  hidden
>
  <h2 id="FilterDrawer-Heading">Filter products</h2>
  <button type="button" class="filter-drawer__close" aria-label="Close filters">
    <svg aria-hidden="true" focusable="false">...</svg>
  </button>
  ...
</div>

A few things merchants get wrong:

  • role="dialog" with no label is announced as a generic “dialog.” Always tie it to a heading.
  • aria-modal="true" does not trap focus. You have to wire that up in JavaScript.
  • display: none toggled via class is not equivalent to the hidden attribute. Use hidden so assistive tech is never confused about whether the drawer is open.
  • The close button needs a real text label. An SVG with no accessible name is the most common single failure in the entire pattern.

Focus management mirrors the cart drawer pattern. When the drawer opens, save the trigger, move focus into the drawer, listen for Escape, trap Tab inside. When it closes, restore focus to the trigger. If you need a working example, the cart drawer guide linked at the bottom of this post has copy-paste code.

Result counts, clear buttons, and the live region

This is where 4.1.3 lives, and it is the criterion most stores fail silently. When a filter is toggled, three things change visually. The product grid, the result count, and often the URL. A keyboard or screen reader user is told none of it unless you put a polite live region in the page and write to it after every update.

<p
  class="visually-hidden"
  aria-live="polite"
  aria-atomic="true"
  data-collection-live
></p>
function announceResults(count, filterLabel) {
  const region = document.querySelector('[data-collection-live]');
  if (!region) return;
  region.textContent = '';
  requestAnimationFrame(() => {
    region.textContent = filterLabel
      ? filterLabel + ' applied. ' + count + ' products.'
      : count + ' products.';
  });
}

Why the empty-then-write? Some screen readers ignore identical updates. Clearing first and writing on the next frame forces a fresh announcement when a user toggles two filters in quick succession.

Two more details:

  • Use aria-live="polite". Filter updates are not interruptions. Save assertive for errors, for example “No products match your filters.”
  • Update the live region after the grid has finished rendering, not before. If you announce before the fetch returns, you will lie to the user.

The clear all filters button has its own quirks. Most themes hide it when no filter is active, which is fine, but they often remove it from the DOM. The next time a filter is applied, the button reappears and steals focus order in a way that is confusing to keyboard users. Hide it with CSS or with the hidden attribute. Keep the element in place so focus order is stable.

Custom checkboxes and swatches

Two patterns to follow.

For a checkbox group, use a real <input type="checkbox"> with a <label>. Style the input off-screen and style the label visual. Do not invent your own checkbox out of a div. If you must, give it role="checkbox", aria-checked, tabindex="0", a keyboard handler for Space and Enter, and a real accessible name. The runtime snippet above does this. It is the floor, not the ceiling.

For color swatches, the accessible name must include the color. A purple square with no label is invisible to a screen reader. Use aria-label="Purple" on the button, or wrap it in a <label> if it is a real radio input. Do not rely on title. Mobile assistive tech treats title inconsistently.

A short check that catches almost every swatch bug:

document.querySelectorAll('.swatch, .swatch-button').forEach((node) => {
  const name = node.getAttribute('aria-label')
    || node.textContent.trim()
    || node.getAttribute('title');
  if (!name) console.warn('Swatch missing accessible name:', node);
});

Run this in the console on a collection page and you will get a list of every offender. Fix them at the source, then re-run the check.

Infinite scroll and load-more buttons

If your theme appends new products as the user scrolls, two things break. Keyboard users cannot trigger the load, and screen reader users do not know new tiles arrived. The fix is to provide a real load-more button, even if your design hides it behind an intersection observer for mouse users.

<button
  type="button"
  data-load-more
  class="visually-hidden-when-scrolling"
>
  Load more products
</button>

When new tiles append, announce the count of new items in the same live region. Move focus to the first newly added product heading only if the user activated the button by keyboard. Do not steal focus on scroll.

FAQ

Do I have to switch from my filter app to Shopify’s native filtering? No. Native filtering is cleaner under the hood, but the runtime patch pattern works for almost every popular filter app. Switching apps is a project. Patching is a sprint.

Is role="checkbox" on a div really compliant? It can pass automated scanners and screen reader spot checks if you also set aria-checked, tabindex="0", a keyboard handler, and a real accessible name. It is still more fragile than <input type="checkbox">. Use the native element when you control the markup.

Does the URL need to update when filters change? Not for accessibility, but it helps. A URL that reflects the active filters is shareable, recoverable, and easier for screen reader users to bookmark. If your app does not update the URL, do not invent that as an accessibility requirement.

What about price range sliders? A slider is fine if both endpoints have accessible names, aria-valuemin, aria-valuemax, aria-valuenow, and keyboard arrow support. Most stock sliders fail at least one of those. Two number inputs with min and max labels are a safer default.

Do I need a separate fix for the desktop sidebar and the mobile drawer? The controls inside are the same. The container differs. Desktop sidebar is just a region with a heading. Mobile drawer is a modal dialog. Apply the right container pattern based on viewport, and reuse the inner control patches in both.

If your collection page is on fire and you do not have a week to refactor, install the Shopify Accessibility Fixer app. It applies the patches in this post at runtime, including the messy third-party filter app cases, without theme edits. Then come back to this guide and bake the proper fixes into your theme on your next release cycle. Both layers are worth having.