Accessibility · Keyboard and Focus

How the Browser Computes the Tab Sequence

The tree walk behind the Tab key: tabindex partitioning, the sequential focus navigation starting point, and why a positive tabindex can send focus backwards through the page.

Pressing Tab does not advance a pointer through a list the author wrote. The browser rebuilds the candidate set every time the key is pressed, walking the document and applying an ordering rule that has two distinct phases. Most focus bugs that authors attribute to “the browser being weird” are the second phase doing exactly what the specification says.

The candidate set comes from the tree, not from the markup

The browser starts from a notion of focusable area. Any element that can hold focus at all is a candidate, and candidacy is decided by a fixed list of conditions: the element is being rendered, it has a tabindex attribute, or it belongs to one of the natively focusable element types such as a with an href, button, input, select, textarea, iframe, summary as the first child of a details element, and the media elements with controls. An element in a subtree made inert by the inert attribute, or a form control carrying disabled, is excluded before ordering even starts.

Two consequences follow immediately. First, a focusable element that is not rendered — because an ancestor sets display: none, visibility: hidden, or content-visibility: hidden — is absent from the sequence entirely, and CSS that hides content therefore edits the tab order as a side effect. An element hidden only by opacity: 0 or by being translated off-screen is still rendered, still focusable, and still in the sequence, which is why the old trick of parking a control at position: absolute; left: -9999px leaves it tabbable. Second, the candidate set is not stable: any DOM or style change between two Tab presses can change the answer, and the browser does not recompute lazily.

Two passes, one ordered walk

The ordering rule itself is short, and the specification states it as a set of user-agent preferences rather than as one algorithm. Focusable areas whose tabindex value is a positive integer come before the rest, sorted by that integer in ascending order, with equal values resolved in tree order. Everything else — tabindex="0", no tabindex at all, and the natively focusable elements — follows in a single tree-order pass. Elements with a negative tabindex are omitted from the sequence but remain focusable programmatically. The spec’s own note that the ordering “is not necessarily related to the tree order of the Document” is about the positive pass; implementations follow the rule above, which is what the rest of this section depends on.

That is the whole algorithm, and it produces a discontinuity that authors rarely intend. Consider a footer containing a “help” button with tabindex="1", and a header with a navigation link that has no tabindex. The header link is earlier in the document, but the footer button is visited first, because the positive group is drained before the zero group is touched. Add a tabindex="2" in the middle of the page and the sequence becomes footer, middle, then everything from the top of the document down. The visible result is that Tab moves down the page, jumps backwards, then restarts from the top — a pattern that looks like a browser defect and is a faithful execution of the rule.

// The ordering rule, reduced to its two phases. Assumes the elements are already
// known to be focusable and rendered; reality also needs shadow trees and inertness.
function tabSequence(root) {
  const candidates = [...root.querySelectorAll('[tabindex], a[href], button, input, select, textarea')];
  const value = (el) => {
    const raw = el.getAttribute('tabindex');
    const n = raw === null ? 0 : Number.parseInt(raw, 10);
    return Number.isNaN(n) ? 0 : n; // an unparsable tabindex gives a null tabindex value
  };
  const positive = candidates
    .filter((el) => value(el) > 0)
    .sort((a, b) => value(a) - value(b)); // sort is stable, so equal values keep tree order
  const rest = candidates.filter((el) => value(el) === 0); // negatives are omitted entirely
  return [...positive, ...rest];
}

The sort being stable matters: the specification resolves equal tabindex values by tree order, and requires that the second pass preserve tree order exactly. querySelectorAll already returns document order, so a stable sort reproduces both properties.

Negative values have a second use beyond removal. An element with tabindex="-1" can receive focus from script, so a container can be focused on route change or after a dialog closes without joining the Tab sequence. tabindex="-1" on a wrapper around a heading is the usual way to make a region programmatically targetable without adding a stop.

The starting point is not the top of the document

Tab does not always begin at the first element. A browsing context can carry a sequential focus navigation starting point, which is a position in the document rather than an element. Left unset, it plays no part and the walk begins from the currently focused element or from the start of the document. When it is set and lies inside the starting element for the walk, it takes precedence: Tab proceeds forwards from that position and Shift+Tab backwards from it.

Navigating to a fragment is the one case where the specification requires the starting point to be set, and it must be set to the fragment’s target element. That is why a table-of-contents link followed by Tab lands near the section heading rather than at the page banner. The same algorithm also runs the focusing steps for the target, with the document’s viewport as a fallback: a target that is itself focusable is focused, and a target that is not — the ordinary <h2 id="x"> — leaves focus on the viewport, which reads as document.activeElement returning the body. The detail is worth keeping straight, because it means a fragment jump both scrolls and moves the walk, while leaving no focused element to inspect.

The user agent decides when else to move the point; the specification explicitly permits setting it to the position of a click on the document’s contents. That is why the first Tab after a mouse click sometimes starts in the middle of the page. Focusing an element from script does not move the point, but the walk begins from the currently focused area anyway, so the next Tab continues from wherever focus was placed. Code that deliberately calls focus() on a hidden anchor at the top of a page to “reset” the tab order is fighting the mechanism rather than using it, and it also moves focus for the reader, which is what bypass links attempt to avoid.

Where a sequence ends

Each browsing context has its own sequence. Tab through the last focusable element and the walk wraps into the browser’s own chrome — the address bar and toolbar — and eventually returns to the document at the position it left. An iframe contributes exactly one stop to its parent’s sequence: the frame element itself. Descending into the frame’s document is a separate walk, so a page with three embedded frames has a tab order that is a sequence of sequences, and the boundary is invisible to the reader except as a change in what the focus ring is drawn around.

This is also why focus escapes containers that authors believe they have trapped. A keydown handler that intercepts Tab on a container prevents the container’s own cycle from wrapping, but it cannot stop the browser from moving focus out when the handler does not run — for instance when focus is already inside an embedded frame, where the parent document never receives the key event at all. Modal dialogs are handled by the platform for this reason; see Containing Focus Inside a Modal Surface.

Failures that look like ordering bugs

Three failure modes account for most reported problems.

A focused element that is removed from the document takes focus with it. If a validation message replaces the input it refers to, or a list re-renders after filtering, focus falls back to the document body and the next Tab restarts at the top of the page. The sequence did not change; the element that anchored it did. Restoring Focus After the Focused Element Disappears covers the bookkeeping that prevents this.

An element hidden from assistive technology with aria-hidden="true" is still focusable. The attribute removes a subtree from the accessibility tree but has no effect on the tab sequence, so a control inside it remains reachable by keyboard while being absent from the screen reader’s view of the page. The inert attribute is the mechanism that removes both, and the two are not interchangeable.

Finally, ordering is not the same as reading order. Placing elements in a grid, or reordering them with order in a flex container, changes visual position without changing tree order. Keyboard users follow the tree; sighted keyboard users therefore see the focus ring jump around the visual layout. Skip Links and the Sequential Navigation Starting Point describes how the start point partially compensates, and why the compensation is not a substitute for a sensible tree.

The invariant to design against is simple: for a page with no positive tabindex values, the tab sequence is the document order filtered by focusability. Everything that surprises an author is either a positive value, a change to what is rendered, or focus having been moved without the author noticing.

References

  1. HTML Living Standard — Focus — WHATWG
  2. HTML Living Standard — The tabindex attribute — WHATWG
  3. Web Content Accessibility Guidelines 2.2 — Success Criterion 2.4.3 Focus Order — W3C
  4. MDN Web Docs — tabindex attribute — MDN