Accessibility · Keyboard and Focus
Keeping Focus Stable in Virtualized and Reordered Lists
Why windowing libraries lose focus when they recycle nodes, how sorting a list breaks a tab stop, and what content-visibility does to the focusability of skipped subtrees.
A list with ten thousand rows cannot have ten thousand elements in the document. Windowing libraries render a slice and recycle the nodes as the viewport scrolls, and every one of the assumptions a focus handler makes — that an element persists, that its position in the tree is its position in the data, that focus survives a scroll — is false in that arrangement. The interaction between virtualization and focus is the point where the simpler patterns stop working.
Recycling destroys focus silently
The core technique is a fixed pool of row elements translated to different offsets as the user
scrolls. Row 4’s <div> becomes row 40’s content. Nothing about the element is stable except its
identity, and identity is exactly what focus is attached to.
Two failures follow, and they are different problems.
When the focused row scrolls out of the rendered window, the library either removes it or reuses it
for a different item. Either way the focused element stops existing as that row: the removal case
triggers the focus fixup rule and focus falls to the body; the reuse case moves focus to an element
that now displays different data, so document.activeElement still names a valid element but the
user’s position has silently changed to a different item.
The second case is worse because it produces no visible symptom in the DOM. A test that asserts
document.activeElement is inside the list passes, while the user is now focused on row 40 having
been on row 4. Any restoration logic that keys off the node rather than the data is wrong.
The structural fix is to make focus not depend on row identity at all. The active-descendant pattern does this: the container is the only focusable element and the current row is named by an id attribute, so scrolling and recycling change the attribute value rather than the focus target. The comparison of the two approaches in Roving tabindex and Active Descendant Compared applies directly here, and virtualization is the case that most strongly favours the container.
If roving tabindex is required — because the rows contain their own controls, or because the design
needs real focus events — then the row’s tabindex="0" must move with the data, not with the node.
A row entering the pool for a new item gets tabindex="-1", and the row rendering the remembered
item gets 0, decided from the item key at render time.
Scrolling past the focused item
Browsers scroll a focused element into view when it receives focus. In a virtualized list, the
element may not exist until the library has rendered the slice containing it, so a programmatic
focus() on an item outside the window is refused — there is no element to focus, because the
element has not been created.
This forces the focus call to be split: scroll the list so the target item is inside the rendered
range, wait for the library to render it, then focus. The wait is the difficulty. A synchronous
render gives a one-frame delay; an asynchronous one gives no bound at all. Libraries that expose a
render callback or a promise are the reliable route; polling document.querySelector until the
element appears works but leaks timers on any path that abandons the operation.
The alternative is to avoid the problem: keep focus on the container and let the item be indicated rather than focused. Active descendant again, and it also removes the need for the container to scroll a child into view — the container scrolls itself, which is an ordinary scroll operation.
Sorting reorders the tab sequence
Sorting a table by a column header moves rows in the DOM. Three things change that authors rarely account for.
If a row holds focus and the rows are reordered by moving nodes, focus stays with the node, so the user’s focused row is now at a different visual position. That is arguably correct — the user is still on the same item — and it is disorienting, because the focus ring jumps across the viewport while the user’s hand has not moved.
If the rows are re-rendered rather than moved, focus is destroyed and the fixup rule applies. The restoration problem described in Restoring Focus After the Focused Element Disappears is the general case; sorting is its most frequent trigger.
And the tab sequence itself changes, because the sequence is tree order. A user who tabbed to the fifth row’s button and then sorted by a different column will find that button at a different index, and any code that remembered “the fifth row” now refers to a different item. Remembering the item key rather than the index is the only stable identity.
The header that triggers the sort needs to remain focused after the sort completes. It usually does, because it is outside the re-rendered region — unless the whole table including headers is re-rendered, which is the common implementation and the common bug.
What content-visibility changes
content-visibility: auto skips rendering work for off-screen subtrees without removing them from
the document. The specification defines the elements as “skipped” rather than “not rendered”, and
the distinction matters for focus: a skipped subtree is still focusable, and focusing an element
inside it makes the subtree relevant to the user, which causes it to be rendered.
.long-section {
content-visibility: auto;
contain-intrinsic-size: auto 400px; /* placeholder size, keeps the scrollbar stable */
}
So a link inside a content-visibility: auto region remains in the tab sequence even while it is
skipped, and tabbing to it forces the browser to render the region and scroll it into view. This is
the behaviour authors want, and it differs from display: none, which removes the element from the
sequence entirely.
Two caveats. The contain-intrinsic-size fallback determines the scroll height of a skipped region;
if it is wrong, the scroll position shifts when the region is rendered, and a focus-triggered render
can therefore move the page under the user. And content-visibility: hidden — the non-auto value —
does make the subtree not rendered, and does remove it from the tab sequence, which is the value to
use for a collapsed region that must be excluded. The two values are one word apart and behave
oppositely for focus.
Focus and scroll anchoring together
A virtualized list that inserts content above the viewport — new messages in a feed, rows appearing from a poll — shifts everything below it. Scroll anchoring, which browsers implement to keep the visual position stable, adjusts scroll offset when content is inserted above. Focus is not anchored in the same way, so a focused row can move while the scroll offset stays put, and the browser then scrolls the focused element back into view, producing a visible jump.
The mitigation is to insert content outside the focused element’s neighbourhood, or to suppress
automatic insertion while focus is inside the list, or to mark the focused row with overflow-anchor: none so the anchoring algorithm ignores it. All three trade one form of instability for another;
there is no arrangement in which a list that mutates above the viewport keeps both scroll position
and focus position fixed, because they are anchored to different things.
What a keyboard user needs from a long list
The requirements that matter are stable and can be enumerated without reference to any library.
Tab must enter the list once and leave it once. Arrow keys must move within it, and the movement must be visible: the item that arrows act on must be indicated, whether by real focus or by an active-descendant style. Re-entering the list must return to the last item acted on. Deleting or filtering must leave focus on a surviving neighbour, and the item must be identifiable by data key rather than by position. And scrolling the list must not change which item is current.
Every one of those is a property of the focus model, not of the rendering strategy. A virtualized list that satisfies them can be implemented with either mechanism; a list that renders all rows and fails them cannot be rescued by rendering fewer.