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.
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”.
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 |
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.
Related
Permalink to "Related"- Accessible filtering & faceted search — the parent guide and the chip component
- Announcing filter result counts with aria-live — the message that follows the focus move
- Restoring focus after closing complex modals — the same resolve-then-move discipline