Accessible Virtual Scrolling in Vue with vue-virtual-scroller
Permalink to "Accessible Virtual Scrolling in Vue with vue-virtual-scroller"Accessible virtual scrolling with vue-virtual-scroller means adding the positional ARIA metadata that its RecycleScroller strips away when it recycles DOM nodes, so a screen reader can still tell a user which row of how many they are on. The single failure it prevents is the phantom-length list: the scroller mounts six nodes, the screen reader announces “item 3 of 6”, and the user has no idea they are three rows into a dataset of twelve thousand. This guide adds the missing role, aria-setsize, and aria-posinset and keeps keyboard focus alive across recycles. It is the Vue counterpart to making react-window accessible for screen reader users, and both sit under accessible virtualized list patterns.
Spec reference
Permalink to "Spec reference"aria-setsize and aria-posinset are ARIA 1.2 properties for exposing an item’s position within a set that is not fully present in the DOM. aria-setsize is the total count of items in the set; aria-posinset is the 1-based index of an individual item within that total. They are valid on elements with roles such as listitem, option, row, and treeitem. For tabular virtualization, aria-rowcount on the grid and aria-rowindex on each row carry the equivalent information for rows.
RecycleScroller renders a small pool of item components and reuses them as the user scrolls, binding each to a different data record. Its default output is a plain <div> wrapper with no list semantics: absent your intervention, the browser exposes a generic group of a few <div>s, and the total set size is invisible to AT. The relevant success criteria are 1.3.1 Info and Relationships (Level A), 2.4.3 Focus Order (Level A), and 4.1.2 Name, Role, Value (Level A).
When to use — and when not to
Permalink to "When to use — and when not to"Reach for virtual scrolling when the dataset is large enough that rendering every row breaches your DOM node budget or visibly harms scroll performance — typically a few thousand rows and up. Below that, a plain rendered <ul> is both simpler and more robust for AT, which sees every node natively without any aria-setsize bookkeeping.
The misuse to avoid is virtualizing a short list “for consistency”. A 40-item list virtualized into a 6-node pool trades a fully accessible native list for a hand-maintained ARIA contract that can drift out of sync — all downside, no performance win. Virtualize because you must, not by default.
Annotated code example
Permalink to "Annotated code example"RecycleScroller with list semantics and positional metadata
Permalink to "RecycleScroller with list semantics and positional metadata"<!-- SC 1.3.1: role=list makes the recycled container a real list for AT -->
<RecycleScroller
class="user-list"
role="list"
aria-label="Directory — all members"
:items="users"
:item-size="56"
key-field="id"
v-slot="{ item, index }"
>
<!-- Each recycled node is a listitem carrying its ABSOLUTE position -->
<!-- SC 4.1.2: aria-setsize/aria-posinset expose true position to AT -->
<div
class="user-row"
role="listitem"
:aria-setsize="users.length"
:aria-posinset="index + 1"
:tabindex="index === focusedIndex ? 0 : -1"
:data-index="index"
@keydown="onKeydown"
>
{{ item.name }} — {{ item.role }}
</div>
</RecycleScroller>
index from the v-slot is the item’s absolute position in the full items array — not its position in the rendered pool — so aria-posinset is correct even though only six nodes exist. Binding it in the template (rather than in a post-render hook) guarantees the attribute updates in the same render as the content, avoiding the stale-position race.
Keeping focus inside the rendered window
Permalink to "Keeping focus inside the rendered window"// SC 2.4.3: move focus predictably and keep the target mounted before focusing
export default {
data: () => ({ focusedIndex: 0 }),
methods: {
onKeydown(e) {
const last = this.users.length - 1;
let next = this.focusedIndex;
if (e.key === 'ArrowDown') next = Math.min(next + 1, last);
else if (e.key === 'ArrowUp') next = Math.max(next - 1, 0);
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = last;
else return;
e.preventDefault();
this.moveFocus(next);
},
moveFocus(index) {
this.focusedIndex = index; // roving tabindex
// Ensure the node exists before we focus it, or focus falls to <body>
this.$refs.scroller.scrollToItem(index);
this.$nextTick(() => {
const el = this.$el.querySelector(`[data-index="${index}"]`);
if (el) el.focus(); // node is now mounted
});
}
}
};
The order is the whole trick: update the roving tabindex, call scrollToItem so RecycleScroller mounts the target node into the window, wait one tick for the DOM to flush, then focus(). Focusing before the scroll would target a node that has been recycled away, dropping focus to document.body.
Grid mode — rows with aria-rowcount
Permalink to "Grid mode — rows with aria-rowcount" <!-- SC 1.3.1 + 4.1.2: grid semantics for a virtualized data table -->
<RecycleScroller
role="grid"
:aria-rowcount="rows.length"
aria-label="Transactions"
:items="rows"
:item-size="44"
key-field="id"
v-slot="{ item, index }"
>
<div role="row" :aria-rowindex="index + 1" class="txn-row">
<span role="gridcell">{{ item.date }}</span>
<span role="gridcell">{{ item.amount }}</span>
</div>
</RecycleScroller>
aria-rowcount on the scroller is the total dataset row count; aria-rowindex on each recycled row is its absolute 1-based position. Together they let AT announce “row 4,051 of 12,000” instead of “row 3 of 6”.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key / event | Action | Expected announcement | Failure indicator |
|---|---|---|---|
Arrow Down |
Focus next item, scroll into window | “Ada Lovelace, item 4,051 of 12,000” | Stale aria-posinset; wrong position read |
Arrow Up |
Focus previous item | “…, item 4,050 of 12,000” | Focus drops to body after recycle |
Home / End |
First / last item of dataset | “…, item 1 of 12,000” / “…, item 12,000 of 12,000” | Scroller jumps but focus does not follow |
Tab |
Leave the list | Next page control announced | Tab steps through every pooled node |
| (scroll) | Recycle mounts new nodes | Positional metadata stays accurate | “item 3 of 6” — aria-setsize missing |
Integration context
Permalink to "Integration context"This is the Vue-specific application of the general recycling problem laid out in accessible virtualized list patterns; the react-window guide solves the same aria-setsize/aria-posinset problem in React’s idiom. Because virtualization exists to protect performance, pair it with a DOM node budget in CI so a regression that accidentally renders the full list is caught before it ships.
Gotchas
Permalink to "Gotchas"indexversus pool position. Thev-slotindexis the absolute dataset index — use it directly foraria-posinset. If you ever compute position from the node’s DOM order within the pool, every announcement is wrong.- Focusing before the node is mounted.
scrollToItemthen$nextTickthenfocus. Reverse the order and focus lands on a recycled-away node, dropping todocument.body. v-ifon the scroller resetting AT observation. Toggling the wholeRecycleScrollerwithv-ifremounts it and can reset a screen reader’s list tracking. Keep it mounted and swap theitemsarray instead.
FAQ
Permalink to "FAQ"Why does aria-setsize matter if vue-virtual-scroller only renders a few items?
Because the screen reader only sees the handful of DOM nodes the RecycleScroller keeps mounted. Without aria-setsize it announces “item 3 of 12” when the real dataset has 12,000 rows, misleading the user about the list’s size. aria-setsize declares the true total and aria-posinset gives each recycled node its absolute position, so the announcement becomes “item 4,051 of 12,000”.
What happens to keyboard focus when vue-virtual-scroller recycles the focused item?
If the user scrolls the focused element out of the rendered window, RecycleScroller unmounts its DOM node and focus falls back to document.body, silently losing the user’s place. Prevent it by scrolling the target index into the window with scrollToItem before you call focus, so the node exists when focus lands, and by moving focus programmatically on arrow keys rather than relying on native scrolling.
Should I use RecycleScroller or DynamicScroller for accessible lists?
Use RecycleScroller for fixed-height rows and DynamicScroller when row heights vary. Accessibility handling is the same for both: role=list/listitem, aria-setsize, and aria-posinset bound from the item’s absolute index. DynamicScroller needs extra care that measurement reflow does not run between the aria-posinset update and the focus move, so bind the attributes in the item template rather than in a post-render hook.
Related
Permalink to "Related"- Accessible virtualized list patterns — the framework-agnostic recycling problem and its ARIA solution
- Making react-window accessible for screen reader users — the same positional metadata in React
- Setting a DOM node budget in CI — catching a regression that renders the full list instead of virtualizing