Accessibility · ARIA Practice
Custom Widgets Over Virtualized Content
When only the visible window of a list exists in the DOM, the accessibility tree holds a fragment of the data. Position, size, and the active item have to be reported through attributes rather than structure.
A listbox of ten thousand rows renders forty. The accessibility tree, built from the DOM, therefore contains forty options, and every structural fact a reader derives from that tree is wrong: the count, the position of the current item, the presence of the item the user selected five minutes ago and scrolled past. Virtualisation trades memory for the completeness of the document, and accessibility is one of the things paid for the trade. The attributes that repair the loss are small in number and precise in meaning, and they only work if the widget also keeps its active item in the rendered window.
What the tree infers from structure
A reader that encounters a listbox asks the tree three questions: the number of options, the position of the current one, and the identity of the next one after it.
All three are answered from the DOM structure by default. The count is the number of owned option children. Position is the index within that set. The successor is the next sibling. None of these are stored as numbers anywhere; they are computed from the tree at the moment of the query.
When only a window of items is in the DOM, all three answers describe the window, not the data. A reader reports “option 3 of 40” for a row that is the 4,120th of ten thousand. The user has no way to know the list is longer than what they were told, because the tree contains no element that says so.
The mechanism that repairs this is the set-size vocabulary. aria-setsize on an item states the total number of items in the set it belongs to, and aria-posinset states its one-based position in that set. Both are needed for a reader to place the item: a set size without a position leaves the reader unable to say where in the set the item sits, and a position without a set size leaves the total unknown. Their definition is deliberately decoupled from the DOM: they describe a logical set, which is exactly what a virtualised list has.
<ul role="listbox" aria-label="Log entries" tabindex="0" aria-activedescendant="row-4120">
<li role="option" id="row-4120" aria-posinset="4121" aria-setsize="10000"
aria-selected="true">GET /assets/app.js 200</li>
<li role="option" id="row-4121" aria-posinset="4122" aria-setsize="10000">GET /favicon.ico 404</li>
</ul>
aria-setsize="10000" is the total across the whole data set, not across the window, and aria-posinset is one-based. Both are read by readers that support them and ignored by readers that do not, in which case the announcement falls back to the structural count. The values must be kept correct as the data changes, because a stale set size is worse than none: it asserts a total that the user will rely on.
aria-level does the same job for depth in a tree, where the parent chain may be outside the window. A virtualised tree that renders only visible rows has no ancestors in the DOM for a deeply nested node, so the level must be stated explicitly rather than inferred from nesting.
The active item must be rendered
The second constraint follows from the interaction model. With aria-activedescendant, the container points at the active option by ID, and the specification requires that the referenced element be in the accessibility tree. A virtualised list that scrolls the active item out of the rendered window leaves the reference dangling, and the reader falls back to the container, usually announcing the container’s name and nothing about the position.
The mechanisms are covered in roving tabindex and aria-activedescendant as competing models, and the virtualisation case is the decisive argument for activedescendant: real focus inside an item that gets unmounted moves to the document body, and the platform has already made that decision before any handler runs.
Two strategies keep the reference valid. The first is overscan: render a small band of items beyond each edge of the viewport, sized to cover the maximum distance the user can move the active item in one keystroke. Arrow keys move one item, Page Down moves roughly a screenful, and Home or End move to an item that is not rendered at all, which means the scroll position must be computed and the window jumped before the attribute is written.
The second is to track the active item independently of the window and force its rendering. A list that keeps the active index and renders it even when it is outside the visible band has a DOM node outside the viewport, which is a layout cost but a correctness win. The item must then be scrolled into view by the widget rather than by the browser, because a node outside the viewport that receives no real focus will not be scrolled to automatically.
Home and End are the awkward keys. Jumping to item 1 or item 10,000 requires computing the scroll offset from an item index, which for a fixed row height is a multiplication and for a variable height is a lookup into a measurement cache. A list that has not measured the unrendered rows has no honest answer for the offset, and the common approach is an estimate refined on scroll, which means the active item’s position on screen can be wrong after the jump, and the widget scrolls again to correct it. The visible artefact is a jump, then a settle.
Counts, and the listbox role’s own arithmetic
listbox has a peculiarity: when it allows multiple selection, the role requires that every option carry aria-selected explicitly, because the default undefined value means the option does not support selection. In a virtualised list this means the selection state of unrendered items lives only in the data model, and the rendered window must project it correctly. A row that is selected but scrolled away is re-rendered with aria-selected="true" when it returns.
The projection is where a subtle bug lives. A widget that stores selection as a set of IDs and renders aria-selected from that set is correct. A widget that stores selection as an array of indices and also uses indices as render keys is not: inserting an item shifts every index, and the selection silently moves to a different row. The reader then reports a selected item the user did not select, and the visual highlight, if it is derived from the same broken index, agrees with it, so the bug is invisible on screen. Stable IDs as render keys and as selection keys remove the class of failure entirely.
The count announced when focus enters a listbox is derived from the number of options in the tree, which for a virtualised list is the window size. There is no attribute that overrides the count of a listbox the way aria-setsize overrides the count of an item. The practical consequence is that a virtualised listbox announces “40 items” on entry while each item announces “4,121 of 10,000”. The contradiction is unavoidable within the role’s vocabulary, and the mitigation is to avoid announcing the window count at all, which means not putting the count in the listbox’s own name or description, and letting the per-item position carry the information.
Announcements during scroll
A virtualised list that announces every row as it scrolls is unusable. The rows enter the tree because the user scrolled, which is not an event that warrants speech: the user is looking at the list, and a reader in browse mode is already following the visible content.
The failure arises when the widget writes the active item to a live region on every scroll tick, or when the list container itself is a live region. A listbox is not a live region by default, which is correct; making it one turns every window shift into an announcement burst. The relevant guidance is in how live regions turn DOM mutations into speech: additions are the default relevant mutation, and a virtualised list produces additions constantly.
There is a case where an announcement is warranted: a list that streams new items into a bounded window while the user is reading, such as a log tail. That case wants role="log" with aria-relevant="additions" on the container and a bounded number of rendered rows, so that the additions are the actual new entries rather than the recycled window. The distinction is whether the item entered the DOM because of user scrolling or because of new data, and only the widget knows which, which means the widget must suppress announcements for the first and allow them for the second, rather than relying on the region’s attributes to sort it out.
Focus, position, and scroll anchoring
Removing rendered items above the viewport is the standard virtualisation technique, and it changes the scroll height of the container. If the browser’s scroll anchoring is active, it compensates by adjusting the scroll offset to keep the visible content in place, which is what prevents a visual jump. The adjustment is a scroll event, and a widget that syncs its own scroll position from the container’s scrollTop must not treat the anchoring adjustment as a user scroll. A handler that recomputes the window from any scroll event will recompute it correctly, but one that also writes the active item or triggers a live announcement will fire spuriously.
The cleaner arrangement is to keep the container’s scroll height constant by using a spacer element sized to the full data set, with the rendered window absolutely positioned inside it. The container then scrolls naturally, no scroll anchoring adjustment occurs, and the mapping from scrollTop to item index is a division by row height or a binary search over measured offsets. The cost is a spacer whose height is a number that must be recomputed when the data changes, and a scroll height that can exceed the browser’s maximum element height for very large data sets, which is why some implementations scale the scroll range and accept a non-linear mapping, at the price of scrollbar precision.
For a grid or treegrid, the same arithmetic applies per axis, and the row and column headers must remain in the tree even when their cells are virtualised. A grid whose column headers are unmounted when the user scrolls down loses the association between a cell and its column, and the reader announces a cell with no header. Keeping headers rendered, and marking them with aria-colindex and aria-rowindex so that the index survives the window, is the arrangement that keeps cell announcements meaningful.
What remains unrepairable
Three limits do not have an attribute that fixes them.
Find-in-page and reader-mode extraction operate on the document, not on the data model, so a virtualised list is not searchable beyond its window. The user searching for a value they know is in the list will not find it, and no ARIA attribute changes that.
Text selection across unrendered items cannot work, because there are no nodes to select. Copying a range of rows requires the widget to provide its own affordance.
And structural navigation, meaning heading lists, landmark lists, and table summaries, enumerates what is in the tree. A virtualised table that reports a row count derived from its rendered window gives the user a summary that contradicts the aria-rowcount on the table element, if that attribute is present at all. Setting aria-rowcount and aria-colcount on the table, and aria-rowindex and aria-colindex on the rendered cells, is the only way the summary and the per-cell announcement agree. Where the widget cannot keep those consistent, the honest design is to not virtualise the content that carries the meaning.