react nextjs accessibility patterns

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 Relationships
  • 2.4.3 Focus Order
  • 2.4.6 Headings and Labels
  • 3.2.2 On Input
  • 4.1.2 Name, Role, Value
Grouping turns an ambiguous label into a complete one Two versions of the same filter list. Ungrouped, a screen reader announces only checkbox React, checkbox Advanced and checkbox Free, so the user cannot tell which facet each belongs to. Grouped in fieldsets with legends Framework, Level and Price, the same controls are announced as Framework React checkbox and Level Advanced checkbox, which is unambiguous when heard out of context. Ungrouped — labels are ambiguous when heard alone ☐ React ☐ Advanced ☐ Free "React, checkbox" "Advanced, checkbox" "Free, checkbox" Grouped — the legend is announced too <legend> Framework ☐ React "Framework, React, checkbox, not checked" ☐ Next.js Applied filters — each chip removes itself Remove React filter Remove Advanced filter Clear all filters Names include the verb, so they still make sense in a list of links and buttons.

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: none on the input plus a <label> that carries the visual treatment — the input stays in the DOM, focusable and stateful.
  • Keep the checkbox operable: :checked and :focus-visible styles 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.

Where focus goes after a filter chip is removed Three rows of chips. In the first, the second of three chips is activated and focus moves to the chip that takes its place, the former third chip. In the second, the last chip is activated and focus moves to the new last chip. In the third, the only chip is activated and focus moves to the applied-filters heading container, which is made programmatically focusable, rather than falling to the document body. Remove a middle chip → focus the chip that shifts into its place React Advanced (removed) Free — now focused Remove the last chip → focus the new last chip React Advanced — focused Free (removed) Remove the only chip → focus the container, never the document body React (removed) "Applied filters" region, tabindex="-1" — focused Falling to <body> costs the user every tab stop they had made — and nothing reports it.

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" and aria-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.
The same facets behave differently at two breakpoints Two columns compare the desktop and mobile treatments of one facet panel. On desktop the panel is a sidebar form, filters apply on change, focus stays on the control the user just toggled, and the result count is announced politely. On mobile the panel is a modal dialog with a name, focus is trapped while it is open, filters apply on an explicit button labelled with the resulting count, and focus returns to the trigger on close. Desktop — a sidebar form Filters apply on change Focus stays on the toggled control Results are visible beside the panel Count announced politely, once No focus trap — it is not a dialog Mobile — a modal dialog role="dialog" aria-modal="true" Named "Filters", focus moved inside Focus trapped until dismissed Applied by "Show 18 results" Focus returns to the trigger on close Same markup, two contracts: the overlay is a dialog, so it inherits every dialog rule the moment it covers the page.

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, label and button-name rules 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-pressed on 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.