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 nav with a label.
  • SC 3.2.2 (A) — paging should not move focus unexpectedly.
Bare Next and Previous versus cursor pagination with context Comparison: bare controls give no position or feedback; contextual controls name what they load, show the range and an honest total. Bare Next and Previous versus cursor pagination with context✗ Bare Next and Previous"Next, button"No position informationSilent page changeDisabled state unexplained✓ Contextual cursor pagination"Next 25 invoices, button""Showing 26 to 50 of about 12,000"Range announced"No more invoices" at the end

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.

Choosing what to show with cursors Flow: is an exact total cheap? Show it. Is an estimate available? Show "about". Neither? Show the range only. Always announce the new range after a page change. Choosing what to show with cursorsExact total cheap?count query fast"26 to 50 of 12,431"Estimate available?search engines often provide one"26 to 50 of about 12,000"Neitherstreaming or very large data"Showing 26 to 50"End reachedno next cursor"No more invoices"

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” —
Pagination models and the position information they support Matrix of models — offset with page numbers, cursor with exact total, cursor with estimate, cursor only, infinite scroll — against jump to page, exact position, total and suitability. Pagination models and the position information they supportJump to pageExact positionTotalSuitsOffset with page numbersYesYesExactModerate dataCursor with exact totalNoRangeExactLarge dataCursor with estimateNoRangeAboutSearch resultsCursor onlyNoRangeNoneStreams, feedsInfinite scrollNoImplicitVariesAvoid for tasks
Cursor models trade jumping for scale; say only what is true.

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"
  1. 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”.
  2. Name the navigation controls honestly. “Previous 25” and “Next 25” are accurate; numbered page links would promise random access the API cannot provide.
  3. 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.
  4. 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.
  5. 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.

Permalink to "Related"

← Back to Pagination & Result-Set Navigation