aria-setsize and aria-posinset in Virtual Lists

Permalink to "aria-setsize and aria-posinset in Virtual Lists"

aria-setsize declares how many items are in a set; aria-posinset declares an item’s position within it. Browsers normally compute both from the DOM — a list with twelve <li> elements has a set size of twelve. In a virtualized list, the DOM holds twenty items out of twelve thousand, so the computed values are wrong: a screen reader announces “3 of 20” for what is really item 4,032 of 12,480. These two attributes override the computation and make position honest.

This page covers the valid roles, the exact values, the unknown-total case, and the mistakes that produce off-by-one or contradictory announcements. It belongs to accessible virtualized list patterns.

Spec reference

Permalink to "Spec reference"

ARIA 1.2 supports aria-setsize and aria-posinset on listitem, option, treeitem, menuitem variants, radio, tab, article, and — for aria-posinset/aria-setsize in treegrids — row. For table, grid and treegrid as a whole, the equivalents are aria-rowcount (on the container) and aria-rowindex (on rows).

Values:

  • aria-setsize: integer ≥ 1, the number of items in the full set; or -1 if the total is unknown.
  • aria-posinset: integer ≥ 1 and ≤ aria-setsize (when the size is known), the 1-based position.

Both should be set on every rendered item, not only some — mixing computed and authored values in one set confuses browsers. In trees, positions are counted among siblings at the same level, not across the whole tree.

Criteria: SC 1.3.1 Info and Relationships — the size of a collection and an item’s place in it are relationships users rely on for orientation.

Twenty items in the DOM, twelve thousand in the set Mock of a virtual list window showing four rendered options with their data indices and the aria-posinset and aria-setsize values each carries. Twenty items in the DOM, twelve thousand in the setDOM nodeData ind…aria-posinsetaria-setsize1st rendered4031403211248022nd rendered40324033124803rd rendered403340341248020th render…40504051124801posinset is the data index plus one —positions are 1-based2setsize is the full total, identical on everyrendered item
The DOM window is tiny; the attributes describe the whole set.

When to set them — and when not to

Permalink to "When to set them — and when not to"

Set them on every virtualized list, listbox and tree, and on any list where the DOM does not contain the whole set — paginated lists that show “page 3 of 40” inside one list, lazily loaded trees, infinite feeds.

Do not set them on fully rendered lists. The computed values are already correct, and hand-set values that fall out of sync after a filter or deletion make things worse. Do not set them on table rows in a table or grid; use aria-rowindex and aria-rowcount there.

The misapplication to name is setting aria-posinset to the DOM index (the position in the rendered window) rather than the data index. Every item then announces “1 of 12,480” through “20 of 12,480”, whatever part of the list the user is in.

Annotated code example

Permalink to "Annotated code example"
// Render a window of a virtual listbox with honest position metadata
function renderWindow(start, end, total, items) {
  const frag = document.createDocumentFragment();
  for (let i = start; i < end; i++) {
    const el = document.createElement('div');
    el.setAttribute('role', 'option');
    el.id = `opt-${items[i].id}`;
    el.textContent = items[i].label;
    // SC 1.3.1: full total and 1-based DATA position
    el.setAttribute('aria-setsize', total === null ? '-1' : String(total));
    el.setAttribute('aria-posinset', String(i + 1));
    el.style.transform = `translateY(${i * ROW_H}px)`;
    frag.append(el);
  }
  listbox.replaceChildren(frag);
}

// Infinite feed: total unknown until the server says so
let total = null;                        // renders aria-setsize="-1"
async function loadMore() {
  const page = await api.next();
  items.push(...page.items);
  if (page.isLast) total = items.length; // now known: update every rendered item
  renderWindow(winStart, winEnd, total, items);
}
<!-- Tree: positions count siblings at the same level -->
<ul role="tree" aria-label="Accounts">
  <li role="treeitem" aria-level="1" aria-setsize="3" aria-posinset="1" aria-expanded="true">
    Finance
    <ul role="group">
      <li role="treeitem" aria-level="2" aria-setsize="14" aria-posinset="1">Payroll</li>
      <!-- 13 more siblings, possibly not rendered -->
    </ul>
  </li>
</ul>

After a filter, recompute both values from the filtered set. “4,032 of 12,480” becomes “12 of 48” once the user filters to failures; stale values are worse than none.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Situation NVDA JAWS VoiceOver
Known total, item 4,032 “…, 4032 of 12480” “…, 4032 of 12480” “…, 4,032 of 12,480”
aria-setsize="-1" Position only, or “4032 of unknown” Position only Position may be omitted
Attributes on some items only Mixed computed/authored numbers Mixed Mixed
posinset > setsize Unpredictable; often ignored Ignored Ignored
Fully rendered list, no attributes Correct from DOM Correct Correct
Where the user thinks they are, with and without metadata Bar chart comparing the position announced for data item 4,032 in a 12,480-item virtual list with no metadata, with DOM-index metadata, and with correct data-index metadata. Where the user thinks they are, with and without metadataNo metadata3 — "3 of 20"DOM index used3 — "3 of 12,480"Data index used4032 — "4,032 of 12,480"
Only the data-index value tells the user they are a third of the way through.

Integration context

Permalink to "Integration context"

Most virtualization libraries give you the data index for each rendered item — TanStack Virtual’s index, react-window’s index prop, Angular CDK’s index in *cdkVirtualFor — so the correct value is always available. The full implementations are in making TanStack Virtual lists and tables accessible and Angular CDK virtual scroll accessibility.

For trees whose children load lazily, position counts siblings; the loading sequence is in lazy-loading child rows in a treegrid.

Which attributes for this collection? Decision tree choosing between setsize and posinset, rowcount and rowindex, or nothing, based on whether the collection is a list or a table and whether it is fully rendered. Which attributes for this collection?Is the whole collection in the DOM?YesNothing to addthe browser computes size andpositionNo — a list, listbox or treesetsize + posinseton every rendered itemNo — a table or gridrowcount + rowindexcount includes header rows
Tables count rows on the container; lists count items on each item.

Gotchas

Permalink to "Gotchas"

Off by one. Positions are 1-based; data indices usually 0-based. Every implementation that forgets the + 1 announces the first item as “0 of …”, which some readers then treat as invalid.

Headers counted in lists. Group headers inside a listbox are not options and should not be counted. Separate them into role="group" with labels.

Updating totals mid-stream. Changing aria-setsize on rendered items does not announce anything. If the total matters — “all 12,480 loaded” — announce it with a status message.

Design system notes

Permalink to "Design system notes"

Virtual list components should compute position metadata internally from the data index and the collection’s total, accept total: number | null for unknown sizes, and recompute after filtering. Exposing these attributes as props invites DOM-index bugs; deriving them removes the possibility.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
What do aria-setsize and aria-posinset do?

They tell assistive technology how many items are in a set and where an item sits within it, overriding the values the browser would compute from the DOM. That lets virtual lists announce “item 4,032 of 12,480” while rendering only a few items.

What value should aria-setsize have when the total is unknown?

-1. Update it to the real total once it is known, for example when an infinite feed reaches its last page, and announce the total if users need it.

Should aria-posinset be zero-based?

No. It is 1-based. Use the data index plus one, and never the item’s position within the rendered window.

Do position attributes need updating after a filter?

Yes. Recompute both from the filtered set, so that the first visible match reads as “1 of 48” rather than its old position in the unfiltered list.

Do I use aria-setsize on table rows?

No. For tables and grids, put aria-rowcount on the table or grid and aria-rowindex on each row, counting header rows.

Permalink to "Related"

← Back to Accessible Virtualized List Patterns