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-1if 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.
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 |
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.
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.
Related
Permalink to "Related"- TanStack Virtual accessibility — a full implementation
- aria-rowcount for partial grids — the table and grid equivalent
- Lazy-loading child rows — position in lazily loaded levels