Page Size Selectors and Result Range Summaries
Permalink to "Page Size Selectors and Result Range Summaries"Two small controls sit beside nearly every paginated table: a “Rows per page” selector and a summary such as “Showing 51–75 of 1,240”. Both are easy to get subtly wrong. The selector often has no label, or reloads the table every time a keyboard user arrows past an option. The summary is often written with an en dash that some voices skip, or it updates before the rows do, so it briefly lies.
This page covers both controls and the one behaviour that ties them together: what happens to the user’s position when the page size changes. It is part of pagination & result-set navigation.
Spec reference
Permalink to "Spec reference"A native <select> with an associated <label for> is named by the label. On Windows, arrowing through a collapsed select changes its value and fires change for every option passed; on macOS, arrows open the list and change fires only on selection. That platform difference is why “apply on change” can misfire.
SC 3.2.2 On Input (Level A) says changing a control’s setting must not automatically cause a change of context unless the user was told beforehand. Reloading the table with a different number of rows is a change of content, not context, so apply-on-change is allowed — but reloading four times while a user arrows from 10 to 100 is hostile, and on Windows it is what happens.
The range summary is ordinary text. For it to be announced when it changes, it must be inside, or mirrored into, a live region (SC 4.1.3). SC 1.3.1 is served by keeping it adjacent to the table and referencing it from the table with aria-describedby if it is the only statement of the total.
When to offer a page size selector — and when not to
Permalink to "When to offer a page size selector — and when not to"Offer it when row density matters to different users: analysts want 100 rows, a screen magnifier user may want 10 so the page fits the zoomed viewport and scrolling stays manageable. It is a genuinely useful accessibility affordance.
Do not offer sizes the table cannot render well. If 500 rows per page produces a DOM large enough to slow screen readers — see DOM size limits and performance tradeoffs — cap the options.
The misapplication to name is a page size control made from a button group (“10 · 25 · 50”) without a group label or pressed state. If you prefer buttons, wrap them in role="group" with a label and use aria-pressed on the active size.
Annotated code example
Permalink to "Annotated code example"<div class="table-controls">
<!-- SC 1.3.1 + 4.1.2: visible label programmatically associated -->
<label for="page-size">Rows per page</label>
<select id="page-size">
<option>10</option>
<option selected>25</option>
<option>50</option>
<option>100</option>
</select>
<!-- SC 1.3.1: the summary as words; referenced by the table -->
<p id="range-summary">Showing 51 to 75 of 1,240 invoices</p>
</div>
<table aria-describedby="range-summary"> ... </table>
<p role="status" class="visually-hidden" id="table-status"></p> <!-- SC 4.1.3 -->
const select = document.getElementById('page-size');
let pending;
// Debounced apply: one reload after the user settles (SC 3.2.2 spirit)
select.addEventListener('change', () => {
clearTimeout(pending);
pending = setTimeout(applySize, 500);
});
select.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { clearTimeout(pending); applySize(); }
});
async function applySize() {
const size = Number(select.value);
// Keep the first visible record on screen: recompute the page from it
const firstIndex = (state.page - 1) * state.size; // 0-based
state.size = size;
state.page = Math.floor(firstIndex / size) + 1;
await renderPage();
const from = (state.page - 1) * size + 1;
const to = Math.min(state.page * size, state.total);
const text = `Showing ${from} to ${to} of ${state.total.toLocaleString()} invoices`;
document.getElementById('range-summary').textContent = text;
// Focus stays on the select; the status reports the outcome
document.getElementById('table-status').textContent =
`${size} rows per page. ${text}, page ${state.page} of ${Math.ceil(state.total / size)}.`;
}
Keeping the first visible record in view matters more than it looks. A user on page 3 at 25 rows is reading records 51 to 75. Switching to 50 rows and resetting to page 1 throws them back to record 1; recomputing puts them on page 2 (records 51 to 100), with the record they were reading still present.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key / event | Expected announcement | AT-specific deviations |
|---|---|---|
Tab to select |
“Rows per page, combo box, 25” | VoiceOver: “Rows per page, 25, pop-up button” |
Down Arrow (Windows) |
“50” — no reload yet | Debounce holds the reload |
Down Arrow (macOS) |
List opens | Selection fires change once |
Settled or Enter |
“50 rows per page. Showing 51 to 100 of 1,240 invoices, page 2 of 25.” | Polite; NVDA may read it after the option |
| Table entry | Caption, size, then the range summary as description | VoiceOver reads the description after a pause |
Integration context
Permalink to "Integration context"The status region here is the same one the pagination component writes to after a page change, described in announcing page changes in paginated tables. One region, one message per user action.
The Windows select behaviour behind the debounce is the same issue covered for grid editors in select and date editors inside grid cells; both are fixed the same way.
Gotchas
Permalink to "Gotchas"Dashes in ranges. “51–75” with an en dash is read as “51 75” by some voices at default punctuation. “51 to 75” reads correctly everywhere and costs three characters.
Summary before rows. Updating the summary synchronously, then rendering rows asynchronously, produces a moment where the text is wrong. Update it after render.
Totals that are estimates. Search APIs often return “about 1,200”. Say so — “of about 1,200” — rather than presenting an estimate as exact.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Where should the range summary sit relative to the table?
Directly before or after the table, next to the pagination controls, and referenced from the table with aria-describedby if it is the only place the total is stated. Keep it visible — sighted users rely on it as much as screen reader users do.
Should the page size select reload the table on change?
It may, but on Windows arrowing through a collapsed select fires change for every option, so debounce the reload by around half a second or apply it on Enter or blur. Otherwise keyboard users trigger several reloads just by browsing the options.
How should a result range be written for screen readers?
In words — “Showing 51 to 75 of 1,240 invoices” — rather than with an en dash, which some voices skip. Include the total and what the rows are, and update it after the new rows have rendered.
What page should the table show after the page size changes?
The page that contains the first record the user was looking at. Resetting to page 1 loses their place; recomputing the page from the first visible record keeps it.
Related
Permalink to "Related"- Pagination component in Vue — the controls beside this one
- Announcing page changes — the shared status message
- Select editors in grid cells — the same Windows select behaviour