Numbered Pagination
A numbered page strip that collapses long runs to an ellipsis.
6 frameworksIntermediate
A table footer bar: a range readout, a rows-per-page select and two step links.
<!--
The bar that sits under a data table: a range readout, a rows-per-page select
and the two step links.
The select is a real <select> with a real <label for>, not a custom listbox.
Native gets the mobile wheel picker, type-ahead and the platform's own
keyboard model for free, and none of that is worth rebuilding for a control
with four options.
Changing the size navigates rather than mutating in place, and resets to
page 1 - page 5 of a 10-per-page list is nowhere in particular once the size
becomes 50, and silently landing the user somewhere unrelated is worse than
sending them to the top.
-->
<nav class="pgsize" aria-label="Pagination">
<p class="pgsize__range" role="status" aria-live="polite">Showing 1–10 of 48</p>
<div class="pgsize__controls">
<label class="pgsize__label" for="page-size">Rows per page</label>
<select class="pgsize__select" id="page-size" name="pageSize">
<option value="10" selected>10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<a class="pgsize__step pgsize__step--disabled" aria-disabled="true">Previous</a>
<a class="pgsize__step" href="?page=2&pageSize=10" rel="next">Next</a>
</div>
</nav>
<script>
(function () {
var select = document.getElementById('page-size');
// No JS? The select still submits with its form, or the server reads the
// default. This only upgrades the interaction; it does not enable it.
select.addEventListener('change', function () {
var url = new URL(window.location.href);
url.searchParams.set('pageSize', select.value);
url.searchParams.set('page', '1');
window.location.assign(url.toString());
});
})();
</script>| Prop | Type | Default | Description |
|---|---|---|---|
currentPagerequired | number | - | The active page number, starting at 1. |
pageSizerequired | number | - | How many items each page shows. |
totalItemsrequired | number | - | How many items exist across all pages. |
buildHrefrequired | (page: number, pageSize: number) => string | - | Builds the URL for a page number, so pages stay real links. |
onPageSizeChange | (pageSize: number) => void | - | Called with the new page size when it changes. |
ariaLabel | string | 'Pagination' | The button's accessible name. Required for icon-only buttons. |
className | string | - | Additional classes merged onto the root element. |
Changing the page size resets to page 1, which is a decision rather than a convenience - page 5 of a 10-per-page list is nowhere in particular once the size becomes 50, and landing the user at an unrelated offset is worse than sending them to the top. `totalPages` is derived from `totalItems / pageSize` on every render rather than stored, because a cached copy is a second source of truth that drifts the moment the size changes under it. The control is a native `<select>` with a real `<label for>`: native buys the mobile wheel picker, type-ahead and the platform keyboard model for free, none of which is worth rebuilding for four options. Keep the label visible rather than `sr-only` - a bare "10" next to two arrows is a riddle for sighted users too. The select is also the only reason the Next.js tab needs `use client`; wire it to a form GET and the boundary disappears entirely.