Pagination & Result-Set Navigation
Permalink to "Pagination & Result-Set Navigation"A result set larger than one screen has to be broken up somehow, and the choice you make — numbered pages, an explicit load-more control, or scroll-triggered loading — decides whether a keyboard or screen reader user can find their way back to a row they saw a minute ago. This page covers the three models, the announcement each one owes the user, and the focus handling that keeps a page change from dumping the reader back at the top of the document.
It is written for frontend engineers building result navigation into a grid that already has semantic table construction and, often, sorting and filtering. The failure mode it prevents is the quiet one: a pagination bar that is perfectly operable, and gives the user no idea what just changed.
WCAG criteria in scope
Permalink to "WCAG criteria in scope"| Criterion | Level | Why it applies here |
|---|---|---|
| 2.4.3 Focus Order | A | Focus after a page change must land somewhere that preserves meaning |
| 2.4.7 Focus Visible | AA | Small numeric page controls need a clearly visible indicator |
| 4.1.3 Status Messages | AA | The new page position and row range must reach assistive technology without focus |
| 2.4.5 Multiple Ways | AA | Pagination is one route to content; a per-page URL keeps it addressable |
| 1.4.13 Content on Hover or Focus | AA | Hover previews on page numbers must be dismissible and persistent |
| 2.5.8 Target Size (Minimum) | AA (2.2) | Numbered page links are the classic 24×24 CSS pixel failure |
SC 2.5.8 catches a lot of pagination bars. A row of single-digit page numbers rendered at 18 pixels square fails the minimum target size, and the fix — padding, not font size — costs nothing.
Prerequisites
Permalink to "Prerequisites"- Keyboard navigation patterns for paginated data views — the focus-handling detail this page builds on.
- Announcing async loading states in data tables — every page change that fetches needs the same loading discipline.
- DOM size limits and performance tradeoffs — pagination is the cheapest way to keep the accessibility tree small.
- ARIA live regions for dynamic data — where the page-change message goes.
ARIA & HTML spec reference
Permalink to "ARIA & HTML spec reference"| Attribute / element | Value | Apply it to | Common misuse |
|---|---|---|---|
<nav aria-label="Pagination"> |
— | The control bar | Multiple unlabelled nav landmarks on one page |
aria-current="page" |
page |
The active page control | Using aria-selected, which belongs to tabs and options |
aria-label |
Sentence | Each page control | A bare number or glyph as the whole name |
role="status" |
— | The position announcement region | Putting aria-live on the pagination bar itself |
aria-disabled |
true / false |
Next on the last page | disabled, which removes the control from the tab order entirely |
aria-disabled rather than disabled is deliberate on the boundary controls. A truly disabled Next button disappears from the tab order, so a keyboard user tabbing along the bar finds the sequence changing shape as they move between pages. aria-disabled="true" keeps the control reachable and announced as unavailable, which is far less disorienting.
Step-by-step implementation
Permalink to "Step-by-step implementation"Step 1 — Mark up the control bar as a navigation landmark (SC 1.3.1)
Permalink to "Step 1 — Mark up the control bar as a navigation landmark (SC 1.3.1)"<!-- SC 1.3.1: a labelled landmark makes the bar findable by landmark navigation -->
<nav aria-label="Pagination">
<ul class="pagination">
<li><a href="?page=2" aria-label="Previous page, page 2">‹</a></li>
<li><a href="?page=1" aria-label="Go to page 1">1</a></li>
<!-- SC 4.1.2: aria-current is the state, not a CSS class -->
<li><a href="?page=3" aria-current="page" aria-label="Page 3, current page">3</a></li>
<li><a href="?page=4" aria-label="Go to page 4">4</a></li>
<li><a href="?page=4" aria-label="Next page, page 4">›</a></li>
</ul>
</nav>
Real links with real URLs are the strongest choice here: they work before JavaScript loads, they can be opened in a new tab, and the browser history behaves correctly without any intervention.
Step 2 — Announce the new position (SC 4.1.3)
Permalink to "Step 2 — Announce the new position (SC 4.1.3)"// SC 4.1.3: the status region carries what changed, without moving focus
const status = document.getElementById('results-status'); // role="status" in static HTML
function announcePage(page, pages, from, to, total) {
status.textContent = ''; // force a re-announce
requestAnimationFrame(() => {
status.textContent =
`Page ${page} of ${pages}, showing rows ${from} to ${to} of ${total}`;
});
}
The row range matters as much as the page number. “Page 3 of 12” tells a user where they are in the pagination; “rows 101 to 150 of 587” tells them where they are in the data, which is what they actually care about.
Step 3 — Move focus into the results (SC 2.4.3)
Permalink to "Step 3 — Move focus into the results (SC 2.4.3)"// SC 2.4.3: focus lands in the new content, not on document.body
function afterPageRender(container) {
container.setAttribute('tabindex', '-1'); // programmatically focusable only
container.focus({ preventScroll: false }); // reader begins reading the new rows
}
For a Load more control the rule inverts: keep focus on the button, because the user’s next action is very likely to be pressing it again. Announce the change instead — “24 more rows loaded, 74 of 587 shown” — and let the reader explore at their own pace.
Step 4 — Keep the target size honest (SC 2.5.8)
Permalink to "Step 4 — Keep the target size honest (SC 2.5.8)"/* SC 2.5.8: 24x24 CSS pixels minimum, achieved with padding not font-size */
.pagination a {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.75rem;
min-height: 2.75rem;
}
Keyboard interaction contract
Permalink to "Keyboard interaction contract"Screen reader compatibility matrix
Permalink to "Screen reader compatibility matrix"| Assistive technology | Page change announcement | Known deviation |
|---|---|---|
| NVDA + Firefox | Reads the status text promptly after the swap | Reads it twice if the region is also inside a live nav |
| JAWS + Chrome | Reads the status text; announces aria-current on traversal |
May re-read the whole bar if aria-live is on the container |
| VoiceOver + Safari | Reads the status text after a short delay | Drops the message if it is written in the same frame as the swap |
| TalkBack + Chrome | Reads the status text | aria-current is announced only when the control is focused |
The pattern that satisfies all four is the same one used elsewhere on this site: a role="status" region that exists at page load, written once, one frame after the DOM has settled.
Edge cases & failure modes
Permalink to "Edge cases & failure modes"Zero results. An empty result set is a state, not an absence. Announce it explicitly — “No rows match the current filters” — and keep the filter controls reachable. A silent empty table reads exactly like a table that has not finished loading.
The last page shrinks. If a delete on page 12 of 12 leaves that page empty, the user is stranded on a page that no longer exists. Redirect to the new last page and announce the correction: “Page 11 of 11, showing rows 501 to 512 of 512.”
A filter change resets the page. Changing a filter almost always resets to page 1, and the user needs to know both facts. Announce them together: “Filter applied. Page 1 of 4, 187 rows match.”
Sticky pagination overlapping focus. A pagination bar fixed to the bottom of the viewport will cover a focused row in the last visible line. Add scroll-padding-bottom equal to the bar height so scrollIntoView clears it.
Announcing page changes in paginated tables
Permalink to "Announcing page changes in paginated tables"The announcement is where most pagination implementations fail, because the control works and the failure is inaudible to the person testing it. The detail page covers the message copy, the timing and the double-announcement trap: announcing page changes in paginated tables.
Load more versus infinite scroll
Permalink to "Load more versus infinite scroll"Scroll-triggered loading is the pattern that cannot be made accessible without changing it into something else. The detail page shows the load-more implementation and what it fixes: accessible load more versus infinite scroll.
Keyboard shortcuts for result page navigation
Permalink to "Keyboard shortcuts for result page navigation"Power users of large result sets want to move between pages without tabbing to the bar every time. The detail page covers safe shortcut choices and how to make them discoverable: keyboard shortcuts for result page navigation.
Server-side pagination and the count you announce
Permalink to "Server-side pagination and the count you announce"Client-side pagination knows the total up front, so “page 3 of 12, rows 101 to 150 of 587” is trivially available. Server-side pagination often does not: a cursor-based endpoint returns a page and a token, with no total at all. That is a genuine constraint, not an excuse to say nothing.
Announce what you know. With a known total, announce the full sentence. With a cursor and no total, announce the range and the direction: “Showing the next 50 rows, 151 to 200. More results available.” When the last page arrives, say so explicitly — “Showing rows 551 to 587. End of results.” — because the absence of a Next control is a visual signal that carries no weight for a screen reader user until it is spoken.
Approximate totals deserve honesty too. If the backend returns an estimate, phrase it as one: “about 4,000 rows match”. A precise-sounding number that changes between pages is worse than an approximation that is labelled as such.
The other server-side trap is drift. Between page 3 and page 4, rows may have been inserted or deleted, so a user paging forward can see a row twice or miss one entirely. Stable cursors solve it properly; where they are not available, announce the risk once when it matters — after a mutation that reorders the set — rather than pretending the set is frozen.
Design system integration notes
Permalink to "Design system integration notes"A pagination component belongs in the system, not in each product, because the accessibility surface is entirely in the details that products reinvent badly: the accessible names, the current-page state, the announcement copy and the target size.
Make the announcement a required part of the component contract. The component should take the page, the page count, the row range and the total, and render both the visible bar and the status message from the same data — so they can never disagree. Products that need different phrasing get a formatter; products that need no phrasing do not get an opt-out.
Tokenise the target size. The 24 by 24 CSS pixel minimum from SC 2.5.8 is a spacing decision, and it is the first thing lost when a designer asks for a denser bar. A --control-min-target token that the pagination component uses for padding makes the constraint visible in the design system rather than discovered in an audit.
Expose the aria-disabled boundary behaviour as the default. Products almost never think about what happens to the tab order on the first and last page, and the default should be the accessible one.
Page size as an accessibility decision
Permalink to "Page size as an accessibility decision"Page size is usually chosen for layout, but it has a direct effect on how usable a result set is by ear and by keyboard. A very large page means a long tab journey to reach the pagination bar, and a long read to reach the end of the rows. A very small page means the user pays the announcement and re-orientation cost far more often than necessary.
Somewhere between 25 and 50 rows suits most data tables. Below 25, the ratio of navigation to content becomes annoying for everyone; above 100, both the accessibility tree and the tab journey get heavy enough to notice. If you offer a page-size control, treat it exactly like a filter: it is a control that silently changes the result set, so it needs an announcement of its own — “Showing 100 rows per page. Page 1 of 6.” — and it should preserve the user’s position as far as the arithmetic allows.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Is infinite scroll ever acceptable?
Only when it is user-initiated. A “Load more” button that appends the next batch is the accessible form of the same idea: it keeps the footer reachable, it gives keyboard users a real control, and it produces exactly one announcement per batch. Loading triggered purely by scroll position has no keyboard equivalent, moves the end of the document away from the user every time they approach it, and gives assistive technology no event to describe.
Where should focus go after a page change?
Into the results, not to the top of the page and not back to the browser default. Give the results container tabindex=“-1” and focus it, so the reader begins reading the new rows. For a Load more control, keep focus on the button — the user is likely to press it again — and announce how many rows were added and what the new total is.
Does each page need its own URL?
Yes, whenever the results are addressable content. A distinct URL per page lets a user bookmark, share and return to a position, and it lets the browser back button behave the way people expect. It also makes the state recoverable after a refresh, which matters most to users who navigate slowly and are most likely to lose their place.
Related
Permalink to "Related"- Sortable & filterable data grids — a filter change resets pagination, and both must be announced together
- Keyboard navigation patterns for paginated data views — the focus mechanics in detail
- DOM size limits & performance tradeoffs — why pagination beats rendering everything
- Announcing async loading states in data tables — the loading half of a page change
- Resizable, reorderable & frozen columns — the other toolbar that changes the grid under the user