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.

What a page-change message should contain Comparison of page-change announcements that carry position, range and total against messages that confirm only that something happened. What a page-change message should contain✓ Complete"Page 3 of 12, showing rows 101 to 150 of 587""Page 1 of 4, 187 rows match" after a filterreset"Page 11 of 11, showing rows 501 to 512 of 512"✗ Incomplete"Page changed""Page 3"Silence, because the control worked and nothingspoke
Two numbers orient the user: where in the pages, and where in the data.

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.

When to write the message Timeline of a page change showing the control activation, the row swap, a one-frame settle and the single status write. When to write the messageActivatedrequest startsRows swappedDOM replaced+1 framelayout settlesStatus writtenannounced oncetime across one page change →
Writing in the same frame as the swap is the reason VoiceOver users hear nothing.

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.

Announcement sources that collide Matrix of three common announcement sources on a paginated table and what happens when more than one is active at once. Announcement sources that collideWhat it announcesCollision symptomrole="status" summaryposition, range and totalnone — this is the one to keeparia-live on thetableevery inserted rowa minute of speech per page changearia-live on thepagination barevery page numberthe numbers 1 to 12 read aloud
Pick exactly one source of truth for the page message.

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.

Permalink to "Related"

Back to Pagination & Result-Set Navigation