beforefocus/focusNext (Explainer)
Authors: Marat Abdullin
Introduction
Browsers include complex heuristics for determining the next element to focus, but these aren’t exposed to web developers, leaving them to implement these heuristics from scratch, often requiring DOM modificatons, or writing code that causes reflows and style recomputations. It can be hard or sometimes impossible to apply in some particular scenarios (for example, you might have control of a parent list component, but not the contents of the list items).
While having additional new high level abstractions to handle keyboard navigation (such as the focusgroup) will be useful, there is a multitude of different scenarios for focus management, and lower level primitives will give web developers more flexibility.
This proposal focuses on having two fairly simple lower level functions which would arguably allow the developers to implement whatever patterns they prefer without working around the browser limitations and impossibilities.
Namely, one method called document.focusNext() and one event called beforefocus. Let’s dive deeper into it.
document.focusNext()
document.focusNext() method just exposes the default action the browsers have for Tab and Shift+Tab presses.
The method’s signature should be like:
focusNext(options?: {
contextElement?: HTMLElement,
direction?: FORWARD | BACKWARD,
trapped?: boolean,
includeContextElement?: boolean,
});contextElement is the starting point from which the browser should start looking for the next element to focus. It is optional and equals to document.activeElement by default.
direction is the direction of the search. FORWARD to get the Tab press behaviour and BACKWARD to get Shift+Tab press behaviour. It is also optional and equals to FORWARD by default.
trapped means that instead of going to the address bar after the last element, the search should continue from the beginnig of the page (or from the end of it in case of moving backwards). False by default.
By setting includeContextElement to true, focusNext() will also include contextElement in the search for next element to focus (and focus it if it is focusable). False by default. In some scenarios it is more convenient to start considering elements including contextElement, for example, if you have a group of focusable elements that needs to be skipped, it could be more convenient to specify next group’s container instead of locating last deepest child of the current group that needs to be skipped.
This method should invoke exactly the same internal routine to locate and focus elements which the currently existing default action for Tab and Shift+Tab invokes.
The important expected behaviours:
- If the
contextElementis the last element in the DOM, callingdocument.focusNext()moves focus outside of the page to the browser’s address bar — the same thingTabpress default action does. - If an
<iframe>comes after thecontextElement, callingdocument.focusNext()should move focus to the first focusable element inside the iframe (if the iframe has focusable elements) or past the iframe (if there is nothing to focus in the iframe) — the same thingTabpress default action does. - The same logic applies for mirroring
Shift+Tabbehaviour whendirectionis set otBACKWARD. document.focusNext()should have no effect if the page is not focused or the focus is in the address bar.- When
trappedis true, the focus should cycle inside the page, skipping the address bar.
beforefocus Event
beforefocus event is dispatched before the browser wants to focus an element. Most important part is that preventDefault() of the beforefocus event should prevent the element from receiving focus.
The event should have an important property to distinguish the reason for the focusing:
interface BeforeFocusEvent extends FocusEvent {
reason: FOCUS_FORWARD | FOCUS_BACKWARD | PROGRAMMATIC | AUTOFOCUS | UI
}FOCUS_FORWARD means that the element is about to be focused because Tab is pressed or document.focusNext() is called with direction set to FORWARD.
FOCUS_BACKWARD means that the element is about to be focused because Shift+Tab is pressed or document.focusNext() is called with direction set to BACKWARD.
PROGRAMMATIC means that the element is about to be focused because the application has called element.focus().
AUTOFOCUS means that the element is about to be focused because of the autofocus DOM attribute.
UI means that the element is about to be focused because the user has clicked (or tapped) an element.
It is important that calling preventDefault() actually prevents the element focus.
Some important expected behaviours:
- When you
Tabinto a page from the address bar,beforefocusshould be dispatched for the element the browser wants to focus first and focusing it should be preventable withpreventDefault(). - When something inside an
<iframe>is about to be focused,beforefocusshould first be dispatched in the parent application that contains the iframe, the iframe element should be the target of thatbeforefocusevent instance, allowing the parent application topreventDefault()the focus grab by the iframe. Next, if the parent application has not calledpreventDefault(),beforefocusevent should be dispatched inside an iframe. - When a user clicks an input (or any
tabindex=0element),beforefocusfor that input is dispatched (with thereason=UI) and ifpreventDefault()is called, the input shoudn’t receive focus (effectively leavingdocument.activeElementset to<body>as if the click has happened on an empty space of the page). - When screen readers want to move focus with virtual cursor,
reasonshould be eitherFOCUS_FORWARDorFOCUS_BACKWARD(relative to currently focused element position).
Usage Examples
List Example
Consider the following example — a page containing a list of focusable items:

From the keyboard navigation perspective, it is a common practice that when you are tabbing through the page, only one item of the list receives focus, next Tab moves you outside of the list (otherwise it will take forever to tab through a very long or infinite virtualized list). And to move between the list items, the arrow keys are normally used. It is also a common practice to send focus to the selected item while tabbing through the page.
In the example above, tabbing from the address bar will move to Focusable Item 1, not to the selected one.
First way for working this around is a technique called roving tabindex — you set tabindex=-1 to all list items but the selected one which has tabindex=0. To implement roving tabindex in a, for example, React application, tabindex attribute updates need to be done either in the React tree or as direct DOM manipulations on top of the React life cycle. Doing it in the React tree requires rerendering of the entire list when the selected item changes, doing it as direct DOM manipulations could lead to conflicts and inconsistencies with the components inside the list item (they might legitimately be using/changing tabindex for their own purposes).
Things get even more complicated with roving tabindex when a list item contains multiple focusable components inside — you have to go deep inside the item and make sure every focusable element inside inactive item gets tabindex properly set/unset. Those could be the components owned by different teams and various third party components. Imagine rich chat message with links and buttons inside as an example of such a list item. And when your list item contains a cross origin iframe, roving tabindex technique completely hits the wall.
A cherry on top with roving tabindex is the necessity to carefully maintain the state — if the data layer decides that the currently selected item needs to be removed, you have to make sure that the list is not left in a state when all items are tabindex=-1 — this might be very nontrivial in a complex application.
And of course, mainting roving tabindex is a continuous set of DOM updates which lead to reflows, style recalculations and generally have a toll on the application performance.
After roving tabindex, our next resort is putting two invisible inputs before and after our list. Their purpose is to receive focus when a user tabs through the page and immediately redirect that focus to a selected item. This works in a lot of cases, but it also is hacky and far from ideal. Those dummy inputs need to be inserted in the DOM and maintained properly. In a React application example, they again need either to be a part of React tree or inserted at the proper moment and into the proper place as direct DOM manipulations (making sure that they stay first/last when React rerenders the tree).
Additionally, to prevent the screen readers from choking when the dummy input receives focus and moves it right away, those dummy inputs need to be focusable elements with aria-hidden=true. axe-core that is commonly used to validate the DOM state from the accessibility point, will highlight those elements as violations and you will have to train your testing team to distinguish those intentional violations from the actual violations.
Another complication is that even though those dummy inputs are invisible, you need to position them properly to make sure that focusing them doesn’t cause the scrollable containers to scroll in unexpected ways.
And iframes might ruin the party again — you can call iframe.focus() but that won’t focus the proper element inside the iframe.
That is when beforefocus event might come into play.
listContainer.addEventListener('beforefocus', event => {
...
// When we are moving through the page.
if (event.reason === FOCUS_FORWARD || event.reason === FOCUS_BACKWARD) {
if (
!selectedItem?.contains(event.target) // If the browser is going to focus something other
// than an element from the selected item,
listContainer.contains(selectedItem) && // and the selectedItem is still in the DOM,
!listContainer.contains(event.relatedTarget) // and we are moving into the list from outside of the list.
) {
event.preventDefault(); // Don't focus the element the browser wanted to focus.
document.focusNext({
contextElement: selectedItem, // But do focus the selected item.
includeContextElement: true
});
}
}
...
});Unstolen Focus Example
beforefocus would also be useful for preventing unexpected focus movements in complex applications.
Imagine you have two tabs in the UI: Tab1 and Tab2. You render them at once, but Tab1 is currently active and your focus is inside Tab1 contents. At the same time, you have an iframe inside Tab2 contents, it will become visible when you activate Tab2. But the iframe is an embedded application which doesn’t really know that it’s not active now. But it has autofocus or a programmatic focus on load. Once it loads, the focusing logic inside that yet inactive iframe in Tab2 contents will steal the focus from whatever you currently have focused in your active Tab1. With beforefocus you might do something like:
tabs.addEventListener('beforefocus', event => {
// When an inactive tab claims focus, just tell it no.
if (!activeTabContents.contains(event.target)) {
event.preventDefault();
}
});Summary
A combination of document.focusNext() and beforefocus event would allow to easily implement various high level keyboard navigation abstractions — all kinds of modal dialogs, lists, arrow navigation areas, groups of focusables — without fundamentally arguing about how these abstractions should look like.
These changes are not breaking anything that already exists and don’t seem very complex to implement.
Open UI