Cursor-Based Pagination and Total Counts
Permalink to "Cursor-Based Pagination and Total Counts"Many data APIs use cursor-based pagination: each response returns items plus a cursor for the next page, and there are no page numbers or exact totals because counting is expensive. Interfaces built on them often show only “Next” and “Previous” with no sense of position. Sighted users can at least see that the data changed; screen reader users press Next and hear nothing, with no idea how far through the results they are. Accessible cursor pagination is about giving honest position information without inventing numbers the system does not have.
This guide belongs to pagination & result-set navigation.
Spec reference
Permalink to "Spec reference"- SC 2.4.6 (AA) and SC 4.1.2 (A) — controls are named with what they do.
- SC 4.1.3 (AA) — the new range after paging is a status message.
- SC 1.3.1 (A) — pagination controls grouped in a
navwith a label. - SC 3.2.2 (A) — paging should not move focus unexpectedly.
When to use — and when not to
Permalink to "When to use — and when not to"Use cursor pagination when the backend requires it — very large or frequently changing datasets. Pair it with range summaries and honest totals.
If users need to jump to a specific page or know their exact position, cursor pagination is the wrong model; offer filtering or search instead, or offset pagination where feasible. The misapplication: faking page numbers on top of cursors, which breaks when data changes between requests.
Annotated code example
Permalink to "Annotated code example"<nav aria-label="Invoice pages" class="cursor-pager">
<button type="button" id="prev" aria-disabled="true">Previous 25 invoices</button> <!-- named with what it loads -->
<p id="range">Showing 1 to 25 of about 12,000 invoices</p> <!-- honest total -->
<button type="button" id="next">Next 25 invoices</button>
</nav>
<p id="page-status" role="status" class="visually-hidden"></p>
let start = 1; const stack = []; // cursors for going back
async function go(dir) {
const btn = dir === 'next' ? next : prev;
if (btn.getAttribute('aria-disabled') === 'true') return;
const cursor = dir === 'next' ? page.nextCursor : stack.pop();
if (dir === 'next') stack.push(page.cursor);
page = await api.invoices({ cursor, limit: 25 });
start += dir === 'next' ? 25 : -25;
render(page.items);
const end = start + page.items.length - 1;
range.textContent = `Showing ${start} to ${end}` + (page.estimate ? ` of about ${round(page.estimate)} invoices` : ' invoices');
next.setAttribute('aria-disabled', String(!page.nextCursor));
prev.setAttribute('aria-disabled', String(stack.length === 0));
status.textContent = page.nextCursor ? range.textContent : `${range.textContent}. No more invoices.`; // SC 4.1.3
// focus stays on the activated button (SC 3.2.2)
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected announcement | AT deviations |
|---|---|---|
| Tab to Next | “Next 25 invoices, button” | — |
| Activate Next | “Showing 26 to 50 of about 12,000 invoices” | — |
| At the end | Next announced as unavailable; “No more invoices” | VoiceOver says “dimmed” |
| Previous from page 2 | “Showing 1 to 25…” | — |
| Navigate to nav landmark | “Invoice pages, navigation” | — |
Integration context
Permalink to "Integration context"Page change announcements follow announcing page changes in paginated tables, and range summaries page size selectors and result range summaries. Load more versus infinite scroll is discussed in accessible load more versus infinite scroll. Large-data trade-offs are in pagination versus virtualization for large tables.
Gotchas
Permalink to "Gotchas"Changing data. New items inserted between requests shift positions; ranges are approximate in fast-changing data. Say “newest first” and offer a refresh.
Back navigation. Store cursors in the URL or session so Back returns to the same page.
Estimates that change. If the estimate changes between pages, update it without announcing every change.
Worked example
Permalink to "Worked example"An audit log viewer used cursor pagination with bare arrow buttons. A security analyst using JAWS could not tell whether paging worked or how much was left. The team named buttons “Next 50 events”, showed “Showing 51 to 100 of about 1.2 million events”, announced the range after each page, marked Previous as unavailable on the first page, and added “No more events” at the end. Analysts also gained a “Jump to date” filter, which proved more useful than page numbers would have been.
Common mistakes
Permalink to "Common mistakes"- Unnamed arrow icons.
- Fake page numbers on cursors.
- Silent paging.
- Precise-looking totals that are estimates.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Provide a CursorPager with props for page size, estimate and messages, distinct from the numbered Pager, so teams cannot fake page numbers.
Step-by-step implementation
Permalink to "Step-by-step implementation"- Describe the position without a page number. Cursor APIs do not know “page 7 of 40”. Say what you do know: “Showing 51 to 75” or “Showing 25 results after 14 March”.
- Name the navigation controls honestly. “Previous 25” and “Next 25” are accurate; numbered page links would promise random access the API cannot provide.
- Disable rather than hide. When there is no next cursor, disable “Next 25” with
aria-disabled="true"and keep it in place, so the layout and tab order stay stable. - Handle totals carefully. If the count is expensive, show “More than 1,000 results” or load the total asynchronously and announce it politely when it arrives.
- Move focus to the results. After loading a new batch, move focus to the table caption or results heading and announce the new range.
Edge cases
Permalink to "Edge cases"- Infinite scroll. If cursors drive infinite scroll, add a “Load more” button and a way to skip past the list, as described in the pagination guides.
- Stale cursors. Cursors can expire; when they do, explain and offer to restart from the beginning rather than showing an empty table.
- Sorting changes. A new sort invalidates the cursor; reset to the first batch and say so.
Testing it with a screen reader
Permalink to "Testing it with a screen reader"Use NVDA to load the first batch and listen to the results heading: it should say what range is shown and, if available, the total. Activate “Next 25” and confirm that focus moves to the heading and the new range is announced. Keep going until the last batch and check that “Next 25” is announced as unavailable rather than disappearing. Then change the sort and confirm the reset message. In VoiceOver, check that the asynchronous total, when it arrives, is announced once politely and does not interrupt reading. Finally, simulate an expired cursor and verify that the error message explains what happened and offers to restart.
FAQ
Permalink to "FAQ"Can cursor pagination show page numbers?
Only if the backend can compute them reliably. Otherwise show ranges.
Should "about" totals be rounded?
Yes, to a meaningful precision (about 12,000), so they are not mistaken for exact counts.
Should focus move to the first new row?
Usually not; keep focus on the button and announce the range. Users can move into the table when ready.
Is "Load more" better than Next and Previous?
For browsing it can be; it appends rows and keeps earlier ones. Announce how many were added.
How do I support Back after paging?
Store the cursor stack in the URL or history state.
What if the total is unknown and huge?
Say so honestly (“many results”) and encourage filtering.
Related
Permalink to "Related"- Announcing page changes — messages
- Load more versus infinite scroll — alternatives
- Page size selectors and range summaries — ranges