Skip to content
Open UI

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 FocusNavigation feature 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 returns false and has no effect
  • The beforefocus event 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 true if focus was successfully moved to an element
  • Returns false if:
    • No focusable element was found
    • The beforefocus event 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 and advanceFocus() returns false

When stayWithinElement is not specified:

  • wrap: true: Focus wraps from the end of the page to the beginning
  • wrap: 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 as Tab.
  • 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 returns false.

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: true
  • cancelable: true
  • composed: true (crosses shadow DOM boundaries)

The reason Property

The reason property indicates why the element is about to receive focus:

ValueTriggered 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:

  1. beforefocus (on the target element, cancelable)
  2. blur (on the previously focused element)
  3. 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:

  1. Parent frame first: The beforefocus event fires on the <iframe> element in the parent document first
  2. Cancellation at parent: If the parent cancels the event, focus does not enter the iframe
  3. Child frame next: If not cancelled, beforefocus fires 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 beforefocus on the iframe element, but child never fires beforefocus on its elements
  • If child has the meta tag but parent doesn’t: Parent doesn’t receive beforefocus, but child elements fire beforefocus normally

advanceFocus and Iframes

  • advanceFocus() naturally navigates into and out of iframes, following the same tab order as the Tab key
  • 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();
});

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.