Accessible Filter Chips & Facet Panels
Facet panels look simple and hide three separate accessibility problems: the controls themselves need grouping so their labels make sense out of context, the "applied filters" chips need names that say what activating them does, and removing a chip destroys the element that had focus. Each has a small, boring fix. This guide covers all three, and the wrapper question that comes before them — whether the panel should be a form, a set of disclosures, or a dialog on mobile.
It is a companion to Accessible Search & Filtering in React, which covers the search input and the result announcements these filters feed.
WCAG Success Criteria Addressed:
1.3.1 Info and Relationships2.4.3 Focus Order2.4.6 Headings and Labels3.2.2 On Input4.1.2 Name, Role, Value
Prerequisites
- A results list that already announces its count — see announcing search result counts to screen readers.
- Filter state you can serialise, ideally into the URL, so Back behaves.
- A design that can tolerate real
<input>elements underneath the chip styling. If it cannot, the design needs revisiting before the code does.
Group the Controls, Then Style Them
A facet is a group, and HTML has an element for that. <fieldset> plus <legend> is announced with each control inside it, which is what makes "Framework, React, checkbox" possible.
Implementation Guidelines:
- Wrap each facet in
<fieldset>with a<legend>naming it. Style the legend however you like; screen readers use it regardless. - Use checkboxes for multi-select facets and radios for single-select. The role difference tells the user how many they can pick before they try.
- Style chips with
appearance: noneon the input plus a<label>that carries the visual treatment — the input stays in the DOM, focusable and stateful. - Keep the checkbox operable:
:checkedand:focus-visiblestyles on the label give both state and focus feedback.
<fieldset className="facet">
<legend>Framework</legend>
{frameworks.map((framework) => (
<label key={framework.id} className="chip">
<input
type="checkbox"
className="chip-input"
checked={selected.includes(framework.id)}
onChange={() => toggle(framework.id)}
/>
{framework.label}
</label>
))}
</fieldset>
/* The input stays; only its default rendering is replaced. */
.chip-input {
appearance: none;
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
}
.chip {
position: relative;
display: inline-flex;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.35rem 0.75rem;
}
.chip:has(.chip-input:checked) { border-color: var(--primary); }
.chip:has(.chip-input:focus-visible) { outline: 3px solid var(--focus); outline-offset: 2px; }
The :has() selector is what makes this work without JavaScript classes: the label reacts to its own input's state, so the visual and the semantics cannot drift apart.
Applied-Filter Chips Are Buttons
The row of "currently applied" chips is a different control from the facet checkboxes, even when it looks identical. Each one is a button that removes a filter, and its name must say so.
Implementation Guidelines:
- Name the button with the action and the target: "Remove React filter". A bare "React ×" is announced as "React", which sounds like a link to something.
- Do not add
aria-pressed. These are not toggles; they perform one action and then disappear. - Include a "Clear all filters" button when more than one chip is applied, and put it last so it is not hit by accident.
- Announce the new count through the shared status region after any removal, exactly as for a search.
<div className="applied-filters">
<h3 className="visually-hidden">Applied filters</h3>
{applied.map((filter) => (
<button
key={filter.id}
type="button"
// A11y rationale: 2.4.6 Headings and Labels — the name states the action,
// so it is unambiguous when read from a list of controls.
onClick={() => remove(filter)}
>
Remove {filter.label} filter
</button>
))}
{applied.length > 1 && (
<button type="button" onClick={clearAll}>Clear all filters</button>
)}
</div>
Focus When a Chip Disappears
Activating a removal chip destroys the element that had focus. Left alone, focus collapses to <body> and the user loses their place — the same silent failure as a modal closing without restoring focus.
Implementation Guidelines:
- After removing a chip, move focus to the chip that took its index; if it was the last one, move to the new last chip.
- When the last chip goes, focus a stable container — the applied-filters region with
tabindex="-1"— so focus never falls to<body>. - Do the focus move in an effect that runs after the re-render, using a ref to the container and a stored index.
- Announce the resulting count in the same breath, so the user knows what the removal did.
const containerRef = useRef<HTMLDivElement>(null);
const pendingIndex = useRef<number | null>(null);
useEffect(() => {
const index = pendingIndex.current;
if (index === null) return;
pendingIndex.current = null;
const buttons = containerRef.current?.querySelectorAll<HTMLButtonElement>('button');
if (!buttons?.length) { containerRef.current?.focus(); return; }
// A11y rationale: 2.4.3 Focus Order — land on the chip that took this slot,
// or the last one if the removed chip was at the end.
(buttons[Math.min(index, buttons.length - 1)]).focus();
}, [applied]);
Mobile: a Panel, Not a Trap
On narrow screens the facet panel usually becomes a full-screen overlay, which changes its accessibility contract: now it is a dialog, and the dialog rules apply.
Implementation Guidelines:
- Give the overlay
role="dialog"andaria-modal="true", with an accessible name ("Filters"). - Move focus into the panel on open and back to the trigger on close, and trap focus while it is open — see Keyboard Navigation Patterns for Modals.
- Decide whether filters apply immediately or on an "Apply" button. On mobile, an explicit Apply is usually better: it avoids the results changing behind a panel the user cannot see.
- Include the result count on the Apply button ("Show 18 results") so the outcome is known before dismissing the panel.
How to Verify
- Out-of-context listen. With a screen reader, Tab to a single checkbox deep in the panel. The announcement must include its facet name; if it does not, the fieldset or legend is missing.
- Removal focus check. Remove a middle chip, then the last chip, then the only chip. Focus should land on a real control every time, never on
<body>. - Count announcement. Toggle two filters and confirm exactly one message per change, through the same region the search uses.
- Automated scan. axe's
form-field-multiple-labels,labelandbutton-namerules catch the common wiring mistakes — see Component Testing with jest-axe. - Back-button check. Apply three filters, press Back twice, and confirm the checkboxes, the chips and the announced count all agree.
Common Accessibility Mistakes
- Chips built from
<div onClick>. No role, no keyboard, no state. Real inputs styled as chips cost nothing extra. - Facets without a fieldset. Every checkbox is announced without its group, so "Advanced" could mean level, difficulty or support tier.
- Removal chips named "React ×". The multiplication sign is either announced literally or dropped, leaving a control that sounds like a link.
aria-pressedon removal chips. They are not toggles; they vanish when activated, so a pressed state is never true for long enough to mean anything.- Focus dropped on removal. The most common bug in this component, and completely invisible unless you test with the keyboard.
- Applying filters instantly inside a mobile overlay. The results change out of sight, and the count the user finally sees is unexplained.
Conclusion
Facet filtering is three small contracts stacked together: group the controls so their labels survive being heard alone, make applied-filter chips buttons whose names state the action, and take responsibility for focus when a chip removes itself. Native inputs styled with :has() cover the first, a verb in the button name covers the second, and a stored index plus a focusable container covers the third. None of it is difficult — but all three are invisible on screen, which is exactly why they are worth testing with the keyboard and a screen reader before shipping.
Frequently Asked Questions
Can I use buttons with aria-pressed instead of checkboxes for facets?
It is technically conformant, and it is more work for less benefit. Checkboxes announce their group, their state and the fact that multiple selections are allowed, all without extra ARIA. Toggle buttons force you to reimplement each of those, and the moment someone forgets aria-pressed the state disappears entirely.
Should the facet panel be a form with a submit button? On desktop, applying on change is fine and expected, provided each change announces the new count. On mobile, where the panel covers the results, an explicit "Show 18 results" button is clearer and avoids surprising changes behind an overlay.
How do I handle facets with dozens of options? Show the first eight and add a "Show all 34 Framework options" disclosure. That keeps the tab sequence short by default, and the disclosure button's name tells the user exactly what expanding will give them.
Do disabled facet options need to stay in the DOM?
Prefer showing them with a zero count rather than removing them — "Vue (0)" tells the user the facet exists and is empty for this query. If you disable them, use aria-disabled so they remain discoverable, rather than the disabled attribute which removes them from the tab order entirely.
Where should the applied-filters row live? Directly above the results and after the status region in the DOM, so a screen reader user encounters the count, then what produced it, then the results themselves. Keeping it out of the facet panel also means it stays visible on mobile once the panel is dismissed.
Related guides
- Accessible Search & Filtering in React — the parent guide covering the full interaction.
- Announcing Search Result Counts to Screen Readers — the shared status region.
- Keyboard Navigation Patterns for Modals — the rules the mobile filter overlay inherits.
- Accessible Form Validation & Error States — grouping and labelling patterns for form controls generally.