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.

Two ways to apply a new page size Comparison of applying page size on the change event against applying it with an explicit button or on blur. Two ways to apply a new page size✗ Apply on every changeWindows arrowing fires change per optionFour reloads between 10 and 100Each reload may announce a new rangeFocus can be lost if the select re-renders✓ Apply deliberatelyApply on Enter, blur or an Apply buttonOne reload, one announcementOr debounce change by about 500 msSelect keeps focus throughout
Apply-on-change is fine on macOS and a reload storm on Windows; guard it or add a button.

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
Reloads while arrowing from 10 to 100 on Windows Bar chart of how many table reloads occur when a keyboard user arrows a page size select from 10 to 100 under three apply strategies. Reloads while arrowing from 10 to 100 on WindowsApply on change3 reloads — one per option passedDebounced 500 ms1 reloadsApply on Enter or blur1 reloads
A debounce or an explicit apply turns a reload per option into a single reload.

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.

Keeping the user's place when the page size changes Flow showing a user on page 3 at 25 rows per page, the first visible record index 51, a change to 50 rows, and the recomputed page 2 showing records 51 to 100. Keeping the user's place when the page size changesPage 3 at 25records 51 to 75visibleFirst recordindex 51New size 50floor(50 ÷ 50) + 1Page 2records 51 to 100Announcesize, range andpage
Recompute the page from the first visible record — the record the user was reading stays on screen.

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.

Permalink to "Related"

← Back to Pagination & Result-Set Navigation