Clearing Filters Without Losing Keyboard Focus

Permalink to "Clearing Filters Without Losing Keyboard Focus"

Removing a filter chip removes the element that currently has focus. Unless the code decides where focus should go before the removal happens, the browser sends it to document.body — and a screen reader treats that as being thrown back to the top of the page. This page covers the removal sequence that prevents it, the focus target for each removal scenario, and the announcement that has to come after the focus move rather than before it.

It is the focus detail beneath accessible filtering and faceted search, and it applies the same discipline as restoring focus after closing complex modals.

Spec reference

Permalink to "Spec reference"

SC 2.4.3 Focus Order (Level A) requires that if a page can be navigated sequentially and the navigation sequences affect meaning or operation, focusable components receive focus in an order that preserves meaning and operability. Destroying the focused element and leaving the browser to pick a fallback does not preserve either.

SC 4.1.3 Status Messages (Level AA) applies to the second half: the result set changed, and a message must reach assistive technology without receiving focus.

There is no ARIA attribute for “focus went somewhere sensible”. This is entirely an implementation discipline: resolve the target first, mutate second, focus third, announce fourth.

Focus targets after a removal Matrix of removal scenarios against the correct focus target and the announcement each should produce. Focus targets after a removalFocus targetAnnouncementOne chip of severalremovedthe next chip, else the previousnames the removed filter and the new countThe last chip removedthe Clear all control, else the filterregionsays all filters are clearedClear all pressedthe Clear all control itself, now disabled,or the first facetnames how many filters were clearedA filter removed fromelsewhereleave focus where the user put itstill announce the count change
Every scenario needs a target; document.body is never one of them.

When to use — and when not to

Permalink to "When to use — and when not to"

Apply this sequence to every control that removes itself: filter chips, removable tags, dismissible rows, closable panels. Anywhere the activated control disappears as a result of being activated, the same four steps apply.

You do not need it where the control survives its own activation — a checkbox being unchecked, a toggle flipping state. Those keep focus naturally, and moving it would be actively harmful.

The misapplication to watch for is moving focus somewhere “safe but far”, such as the page heading. It is technically valid and practically disorienting: the user was working in the filter area and now has to navigate all the way back.

Annotated code example

Permalink to "Annotated code example"
// SC 2.4.3: resolve the target BEFORE the DOM changes
function removeFilterChip(chip) {
  const list = chip.closest('.chips');
  const target =
       chip.nextElementSibling?.querySelector('button')
    || chip.previousElementSibling?.querySelector('button')
    || document.getElementById('clear-all-filters')
    || list;                                   // last resort, tabindex="-1"

  const label = chip.dataset.filterLabel;      // captured before removal

  chip.remove();                               // focus is now detached
  const count = applyFilters(currentFilters()); // requery + re-render

  if (target === list) list.setAttribute('tabindex', '-1');
  target.focus();                              // focus lands somewhere meaningful

  // SC 4.1.3: announce AFTER the focus move so the two do not overlap
  requestAnimationFrame(() => {
    announce(`${label} filter removed. ${count} rows match.`);
  });
}
<!-- SC 4.1.2: the chip is a button named for what it does -->
<li>
  <button type="button"
          data-filter-label="Region: Nordics"
          aria-label="Remove filter: region Nordics">
    Region: Nordics <span aria-hidden="true">×</span>
  </button>
</li>

<!-- aria-disabled, not [disabled] — the control stays reachable -->
<button type="button" id="clear-all-filters" aria-disabled="false">
  Clear all filters
</button>

Capturing label before the removal is a small detail with a real consequence: reading it from the detached node afterwards works in most browsers and fails in some framework re-render paths, producing an announcement that says “undefined filter removed”.

Choosing the focus target before removal Three-step sequence for removing a filter chip: resolve the next focus target while the DOM is intact, remove and requery, then focus and announce. Choosing the focus target before removalResolve the targetnext chip, else previous chip, else Clear all, else the filterregionWhile the DOM still contains everythingRemove and requerychip removed, results refreshedFocus is momentarily on a detached nodeFocus, then announcefocus the target; "Region filter removed. 210 rows match."Never announce before focus lands
The order is fixed: resolve, remove, focus, announce.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Key Context Action Expected result
Enter / Space Filter chip Remove that filter Focus moves to the next chip; removal and count announced
Delete / Backspace Filter chip Remove that filter Same as Enter — a convenience, never the only route
Enter Clear all Remove every filter Focus stays on the control; “3 filters cleared, all 587 rows shown”
Tab Chip list Move through chips in order Every chip is reachable; the list does not trap focus
Chip names that describe the action Comparison of accessible names for filter chips that state what pressing them does against names that only state the value. Chip names that describe the action✓ Names the action"Remove filter: region Nordics""Clear all 3 filters"The visible text stays "Region: Nordics ×"✗ Names only the value"Nordics""×""Region: Nordics" with the action left implicit
A chip is a button, and a button should be named for its action.

Integration context

Permalink to "Integration context"

The count message here is the same message described in announcing filter result counts with aria-live, composed by the same function. Removing a chip is just another way of changing the filter set, so it should not have its own bespoke phrasing — with one addition, which is naming the filter that was removed.

Where the filter set is also reflected in the URL, a removal updates the URL too. Make sure the history entry is replaced rather than pushed for rapid chip removals, or the browser back button walks the user back through every individual removal.

Gotchas

Permalink to "Gotchas"

The announcement fires before focus lands. The reader is mid-way through describing the new focus target when the status message arrives, and one of the two is dropped — usually the message.

Focus target chosen after the mutation. Reading nextElementSibling from a node that has already been removed returns null, so the fallback chain silently collapses to the last option every time.

Clear all removes itself. If the clear-all control is hidden when no filters remain, it destroys its own focus. Keep it, and use aria-disabled.

A re-render replaces every chip. Frameworks that rebuild the chip list from scratch destroy focus even when the chip the user was on still logically exists. Key chips by filter id so the surviving ones are preserved rather than recreated.

Testing this by hand

Permalink to "Testing this by hand"

The whole failure mode is invisible unless you look for it deliberately, and it takes about a minute to check. Apply three filters so the chip list has several entries. Tab to the middle chip and press Enter. Without touching the mouse, press Tab once more and see where you land: if the next Tab takes you to the site header or the skip link, focus was lost and the browser restarted from the top of the document.

Repeat with the last remaining chip, which is the case most implementations miss — the fallback chain has to reach past an empty chip list to the clear-all control. Then press Clear all while several filters are applied and confirm that focus stays on the control rather than vanishing with the chips it removed.

Finally, run the same three checks with a screen reader running. Focus can appear to be in the right place visually while the reader has been thrown to the top of the page, because a focus move that lands on a container with no accessible name gives the reader nothing to announce. If you hear silence after a removal, the target needs a name or a different target.

FAQ

Permalink to "FAQ"
Where should focus go when the last filter chip is removed?

To the clear-all control if it is still present, otherwise to the container that held the chips, given tabindex=“-1”. Never to document.body: a screen reader treats that as a return to the top of the page, which for a user deep in a filtered result set is a complete loss of context.

Should removing a filter be announced even when focus moves?

Yes, but after the focus move. The focus change announces the new target — a chip name, or the clear-all button — and the status message then adds what happened to the data. Announcing first means the two overlap and the reader usually drops one.

Is it acceptable to disable Clear all when no filters are applied?

Use aria-disabled rather than the disabled attribute, so the control stays in the tab order and is announced as unavailable. A control that disappears from the tab order changes the shape of the page depending on state, which is disorienting for anyone navigating by keyboard.

Permalink to "Related"

Back to Accessible Filtering & Faceted Search