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.