Announcing Page Changes in Paginated Tables
Permalink to "Announcing Page Changes in Paginated Tables"A pagination control that works perfectly and says nothing is the most common accessibility defect in a data table, because it is invisible to everyone testing with their eyes. The rows change, the URL changes, the highlighted page number moves — and for a screen reader user, the only evidence that anything happened is that the button they pressed did not throw an error. This page covers the message that fixes it: what it should contain, when to write it, and why it so often fires twice.
It is the announcement detail beneath pagination and result-set navigation, and it is built on the region mechanics in ARIA live regions for dynamic data.
Spec reference
Permalink to "Spec reference"SC 4.1.3 Status Messages (Level AA) is the governing criterion: where a status message is presented and does not receive focus, it must be programmatically determinable through role or properties so it can be presented to the user by assistive technology. A page change produces exactly that situation — the content changes, focus does not move to an alert, and the user needs to know.
The mechanism is a container with role="status", which carries an implicit aria-live="polite" and an implicit aria-atomic="true". It must exist in the DOM before the message is written; a region created and populated in the same tick is frequently missed, because assistive technology registers live regions when they are inserted.
Nothing in the specification says the message must be visible, and a visually hidden status region is a legitimate and common implementation. That said, a visible “Showing 101 to 150 of 587” line serves everyone and removes the risk that the hidden text drifts out of sync with the visible state.
When to use — and when not to
Permalink to "When to use — and when not to"Announce on every change of the displayed result set: a page change, a page-size change, a jump to first or last. Announce on a filter change that resets pagination too, in one combined message rather than two racing ones.
Do not announce scroll position, and do not announce the same page twice. If the user activates the control for the page they are already on — clicking “3” while on page 3 — say nothing, because nothing changed. A pagination bar that re-announces on every click quickly becomes something the user tunes out.
The misapplication to avoid is putting aria-live on the results table. It is tempting, because the table is the thing that changes, but the reader then announces every inserted row. On a 50-row page that is roughly a minute of speech for a single button press.
Annotated code example
Permalink to "Annotated code example"<!-- SC 4.1.3: the region exists at page load, empty, so AT has registered it -->
<p id="results-status" role="status" class="visually-hidden"></p>
<!-- The visible counterpart — same data, rendered from the same source -->
<p class="results-summary" aria-hidden="true">Showing 101–150 of 587</p>
// SC 4.1.3: exactly one write per real change, one frame after the DOM settles
const status = document.getElementById('results-status');
let lastMessage = '';
function announcePage({ page, pages, from, to, total }) {
const message =
`Page ${page} of ${pages}, showing rows ${from} to ${to} of ${total}`;
if (message === lastMessage) return; // never re-announce an unchanged state
lastMessage = message;
status.textContent = ''; // force a re-announce of identical text
requestAnimationFrame(() => { // let the row swap settle first
status.textContent = message;
});
}
The lastMessage guard is doing real work. Without it, a user who clicks the current page number, or a framework that re-renders on an unrelated state change, produces a repeat announcement that carries no information. The empty-then-write pattern is the complement: it guarantees a re-announce when the text genuinely is identical but the state changed, which happens when a filter narrows the set to the same count.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Assistive technology | Behaviour | Deviation to plan for |
|---|---|---|
| NVDA + Firefox | Announces the status text promptly after the swap | Announces twice if a second live region is present |
| JAWS + Chrome | Announces the status text; reads aria-current on traversal |
Truncates very long messages at a sentence boundary |
| VoiceOver + Safari | Announces after a short delay | Drops the message entirely if written in the same frame as the swap |
| TalkBack + Chrome | Announces the status text | Reads aria-current="page" only when the control is focused |
Integration context
Permalink to "Integration context"The message is one of several signals that must agree. aria-current="page" on the active control is the durable state a user can query by traversing the bar; the status message is the event. Both are needed and they are not interchangeable: a user who returns to the bar five minutes later gets nothing from a message that was spoken once.
Where pagination sits underneath a filter panel, route both announcements through one function so they combine rather than compete. Accessible filtering and faceted search covers the filter half; the combined sentence — “Filter applied. Page 1 of 4, 187 rows match.” — is one write to one region.
Gotchas
Permalink to "Gotchas"Double announcements. Enumerate every live region on the page. There should be exactly one for status, one for alerts, and nothing else. aria-live accidentally left on a container from an earlier iteration is the usual culprit.
The message is written before the rows exist. In frameworks that batch DOM updates, writing the status inside the same handler that sets state means the message describes a state the DOM has not reached. Write it from an effect that runs after commit, or behind requestAnimationFrame.
Zero results announced as a page. “Page 1 of 0, showing rows 0 to 0 of 0” is technically accurate and useless. Special-case the empty set: “No rows match the current filters.”
The visible summary and the hidden message drift. If both exist, render them from the same object. Two independently formatted strings will disagree eventually, and the hidden one is the one nobody notices.
FAQ
Permalink to "FAQ"Should the announcement include the row range as well as the page number?
Yes. “Page 3 of 12” describes the pagination; “rows 101 to 150 of 587” describes the data, which is what the user is actually navigating. Together they let someone decide whether to keep paging or change the filters. If you can only have one, keep the row range.
Why does my page announcement fire twice?
Almost always because two things are announcing: a role=“status” summary and an aria-live attribute somewhere on the table or the pagination bar. Remove every live region except the single status element. The second most common cause is a framework effect that runs twice in development strict modes — check by logging the write, not by listening.
Does the message need aria-atomic?
On a small dedicated status region, no: the region contains only the message, so the reader has nothing partial to read. aria-atomic matters when a live region contains several children and you want the whole thing re-read after any change. For a single-sentence status region it is redundant.
Related
Permalink to "Related"- Pagination & result-set navigation — the parent guide and the control markup
- ARIA live regions for dynamic data — the region this message is written to
- Accessible load more versus infinite scroll — the same message in an appending model