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"
  1. index versus pool position. The v-slot index is the absolute dataset index — use it directly for aria-posinset. If you ever compute position from the node’s DOM order within the pool, every announcement is wrong.
  2. Focusing before the node is mounted. scrollToItem then $nextTick then focus. Reverse the order and focus lands on a recycled-away node, dropping to document.body.
  3. v-if on the scroller resetting AT observation. Toggling the whole RecycleScroller with v-if remounts it and can reset a screen reader’s list tracking. Keep it mounted and swap the items array 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.

Permalink to "Related"

Back to Accessible Virtualized List Patterns