advanceFocus/beforefocus (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 modifications, 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 primitives 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.advanceFocus() and one event called beforefocus.
Status: I have a full-featured implementation for Chromium, it is reasonably simple and straightforward, because everything needed is already present in the browser internally. I work on submitting it to Chromium Gerrit as a prototype behind the
FocusNavigationfeature flag.
Opting In
Both document.advanceFocus() and the beforefocus event require explicit opt-in via a meta tag. This design choice ensures explicit developer intent and prevents the Universe from overheating by unnecessarily dispatching new events on every page:
<meta name="focus-navigation" content="advanceFocus beforefocus">You can enable one or both features independently:
<!-- Enable only advanceFocus -->
<meta name="focus-navigation" content="advanceFocus">
<!-- Enable only beforefocus -->
<meta name="focus-navigation" content="beforefocus">
<!-- Enable both (recommended for most use cases) -->
<meta name="focus-navigation" content="advanceFocus beforefocus">The meta tag values are case-insensitive, so "AdvanceFocus BeforeFocus" works the same way.
Without the meta tag:
document.advanceFocus()exists but returnsfalseand has no effect- The
beforefocusevent does not fire
document.advanceFocus()
document.advanceFocus() exposes the default action the browsers have for Tab and Shift+Tab presses for programmatic usage.
Signature
dictionary AdvanceFocusOptions {
Element? startElement;
Element? stayWithinElement;
boolean backward = false;
boolean wrap = false;
boolean includeStartElement = false;
};
partial interface Document {
boolean advanceFocus(optional AdvanceFocusOptions options = {});
};Return Value
- Returns
trueif focus was successfully moved to an element - Returns
falseif:- No focusable element was found
- The
beforefocusevent was cancelled - The feature is not enabled via meta tag
Options Explained
startElement
The starting point from which the browser should start looking for the next element to focus.
// Start navigation from a specific element
document.advanceFocus({ startElement: myButton });If not specified, defaults to document.activeElement.
backward
The direction of navigation. When false (default), behaves like Tab. When true, behaves like Shift+Tab.
// Navigate forward (like Tab)
document.advanceFocus();
document.advanceFocus({ backward: false });
// Navigate backward (like Shift+Tab)
document.advanceFocus({ backward: true });stayWithinElement
Constrains the focus navigation to stay within a specified container element. Focus will not move outside of this element.
// Only navigate within a specific container
const list = document.getElementById('my-list');
document.advanceFocus({ stayWithinElement: list });This is useful for implementing circular arrow keys navigable lists and tables, custom dropdown menus, focus traps in custom modal dialogs and similar UI patterns.
wrap
Controls whether focus wraps around when reaching the boundaries.
When stayWithinElement is specified:
wrap: true: Focus wraps from last element back to first (or vice versa for backward)wrap: false(default): Focus stops at the boundary andadvanceFocus()returnsfalse
When stayWithinElement is not specified:
wrap: true: Focus wraps from the end of the page to the beginningwrap: false(default): Focus can move to the browser’s address bar (normal Tab behavior)
// Stop at boundaries instead of wrapping
const result = document.advanceFocus({
stayWithinElement: container,
wrap: false
});
if (!result) {
// Reached the boundary - handle accordingly
}includeStartElement
When true, the startElement itself is considered as a candidate for focus if it is focusable. This is useful when you want to to skip some part of the application and continue from a specific place. For example, you have a tree and want to skip the subtree and move to the next tree item on the same level when the down arrow is pressed.
// Focus the startElement if it's focusable
document.advanceFocus({
startElement: myElement,
includeStartElement: true
});Expected Behaviors
- If an
<iframe>comes after the starting element,advanceFocus()moves focus to the first focusable element inside the iframe (if any), or past the iframe — the same behavior asTab. - The same logic applies for backward navigation with
Shift+Tab. advanceFocus()only has effect when the document is focused. It also requires user activation (it doesn’t have an effect when called in some setTimeout callback without an actual user interaction with the page). If focus is in the browser’s address bar or another frame, it returnsfalse.
beforefocus Event
The beforefocus event is dispatched before the browser wants to focus an element. Calling preventDefault() on the event prevents the element from receiving focus.
Event Properties
enum FocusReason {
"page",
"script",
"forward",
"backward",
"ui"
};
interface BeforeFocusEvent : UIEvent {
readonly attribute Element target;
readonly attribute FocusReason reason;
};The event has the following characteristics:
bubbles: truecancelable: truecomposed: true(crosses shadow DOM boundaries)
The reason Property
The reason property indicates why the element is about to receive focus:
| Value | Triggered By |
|---|---|
"page" | The browser wants to move focus (e.g., autofocus attribute) |
"script" | Programmatic focus via element.focus() |
"forward" | Tab key or advanceFocus() |
"backward" | Shift+Tab or advanceFocus({ backward: true }) |
"ui" | User interaction (mouse click, touch) on a focusable element |
Note: For screen reader focus movements, we most likely want to use
"forward"or"backward"value depending on the relative position of the new element compared to the current one.
element.addEventListener('beforefocus', (event) => {
switch (event.reason) {
case 'page':
console.log('Focus via autofocus');
break;
case 'script':
console.log('Focus via element.focus()');
break;
case 'forward':
console.log('Tab pressed or advanceFocus() called');
break;
case 'backward':
console.log('Shift+Tab pressed');
break;
case 'ui':
console.log('User clicked or tapped the element');
break;
}
});Event Timing
The beforefocus event fires before the blur and focus events in the focus sequence:
beforefocus(on the target element, cancelable)blur(on the previously focused element)focus(on the new element)
If beforefocus is cancelled, neither blur nor focus events fire, and focus remains unchanged.
Important Behaviors
- Cancellation prevents focus: Calling
event.preventDefault()stops the element from receiving focus. This is the core functionality that enables focus redirection and prevention. - Bubbles through the DOM: The event bubbles, allowing parent elements to intercept and potentially cancel focus changes for their children.
- Composed: The event crosses shadow DOM boundaries, enabling web components to participate in focus management.
Behavior with Iframes
Both features work across iframe boundaries with specific behaviors designed for security and flexibility:
beforefocus and Iframes
When focus is about to move into an iframe:
- Parent frame first: The
beforefocusevent fires on the<iframe>element in the parent document first - Cancellation at parent: If the parent cancels the event, focus does not enter the iframe
- Child frame next: If not cancelled,
beforefocusfires on the actual target element inside the iframe (if the iframe has opted in)
This allows parent applications to control whether child iframes can receive focus (when, for example, an embedded third party iframe uncontollably wants to steal focus):
// Parent document
iframe.addEventListener('beforefocus', (event) => {
if (shouldPreventIframeFocus()) {
event.preventDefault();
}
});Per-Frame Opt-In
Each frame must independently opt in via its own meta tag:
<!-- Parent document -->
<meta name="focus-navigation" content="advanceFocus beforefocus">
<iframe src="child.html"></iframe><!-- child.html -->
<meta name="focus-navigation" content="advanceFocus beforefocus">- If parent has the meta tag but child doesn’t: Parent receives
beforefocuson the iframe element, but child never firesbeforefocuson its elements - If child has the meta tag but parent doesn’t: Parent doesn’t receive
beforefocus, but child elements firebeforefocusnormally
advanceFocus and Iframes
advanceFocus()naturally navigates into and out of iframes, following the same tab order as theTabkey- When called from a frame that doesn’t have focus, it returns
false - Each frame’s
advanceFocus()operates within the context of that frame unless navigation naturally crosses frame boundaries
Usage Examples
Focus Trap for Modal Dialogs
Implement a focus trap without sentinel elements:
const dialog = document.getElementById('modal-dialog');
dialog.addEventListener('beforefocus', (event) => {
// If focus is trying to leave the dialog, prevent it
if (!dialog.contains(event.target)) {
event.preventDefault();
}
});
// Navigate within the dialog only
document.addEventListener('keydown', (e) => {
if (e.key === 'Tab' && dialogIsOpen) {
e.preventDefault();
document.advanceFocus({
stayWithinElement: dialog,
backward: e.shiftKey,
wrap: true
});
}
});List Navigation with Selected Item
Common pattern: Tab into a list should focus the selected item, not the first item.
<ul id="listbox" role="listbox">
<li tabindex="0">Item 1</li>
<li tabindex="0" aria-selected="true">Item 2 (selected)</li>
<li tabindex="0">Item 3</li>
</ul>const list = document.getElementById('listbox');
list.addEventListener('beforefocus', (event) => {
const selectedItem = list.querySelector('[aria-selected="true"]');
// When tabbing into the list from outside
if ((event.reason === 'forward' || event.reason === 'backward') &&
!list.contains(document.activeElement) && // Coming from outside the list
selectedItem &&
!selectedItem.contains(event.target)) { // Not already targeting selected item
event.preventDefault();
selectedItem.focus();
}
});Preventing Unwanted Focus Theft
Prevent background content from stealing focus (e.g., lazy-loaded iframes):
const activePanel = document.querySelector('.active-panel');
document.addEventListener('beforefocus', (event) => {
// Only allow focus within the active panel
if (!activePanel.contains(event.target)) {
event.preventDefault();
}
});Focus Redirection
Redirect focus to a different element:
const deprecatedButton = document.getElementById('old-button');
const newButton = document.getElementById('new-button');
deprecatedButton.addEventListener('beforefocus', (event) => {
event.preventDefault();
newButton.focus();
});Skip Navigation Links
Implement skip links that work with Tab navigation:
const skipLink = document.getElementById('skip-to-main');
const mainContent = document.getElementById('main');
skipLink.addEventListener('beforefocus', (event) => {
if (event.reason === 'forward') {
event.preventDefault();
document.advanceFocus({
startElement: mainContent,
includeStartElement: true
});
}
});Custom Focusgroup-like Navigation
Skip non-entry items in a focusgroup pattern:
const toolbar = document.getElementById('toolbar');
const items = toolbar.querySelectorAll('.toolbar-item');
const entryItem = items[0]; // First item is the entry point
toolbar.addEventListener('beforefocus', (event) => {
// If navigating to a toolbar item that isn't the entry point
if ((event.reason === 'forward' || event.reason === 'backward') &&
event.target.classList.contains('toolbar-item') &&
event.target !== entryItem) {
event.preventDefault();
// Focus the entry item instead
entryItem.focus();
}
});Summary
The combination of document.advanceFocus() and the beforefocus event provides low-level primitives for implementing various high-level keyboard navigation patterns. These primitives enable developers to implement:
- Modal dialogs with focus traps
- List navigations
- Focus theft prevention
- Skip navigation
- Custom focusgroup patterns
- And much more…
All without relying on DOM mutations, roving tabindex, or invisible sentinel elements.
Open UI