Pagination
Pagination is where you are in a list, not a commit. Page numbers navigate, so they never wear the ink primary. The current page is an inverse fill — the same signal as Tabs pill — plus aria-current="page", so the state does not depend on colour alone.
Anatomy
Twenty rows per page, 412 reviews. The window keeps the first page, the last page, and the neighbours of the current one. Every number is announced as “Page N”; the ellipsis is text with aria-hidden — it is never a button that goes nowhere.
Compact · below md
A wrapped row of numbers costs two lines and tells a phone user nothing they cannot get from Previous and Next. Compact drops the numbers and keeps a status in the middle.
Dashboard · scale="sm"
32px, for a pointer surface sitting under a table. Never on a phone — that is still md, the 44px floor. Prev/Next stay <button> here because a dashboard table has no URL per page.
Specification
| Property | Value | Why |
|---|---|---|
| Element | <nav aria-label="Pagination"> wrapping <ul> | A list of pages, named so it does not collide with the docs chrome (“Previous and next topic”) |
| Page name | Visually hidden Page before the number | A lone “2” fails 2.4.9 and most audits; the nav context is not enough |
| Current page | aria-current="page", not a link, inverse fill, tabindex="-1" after a JS change | A link to the page you are on is a dead control. Colour is not the only signal. Programmatic focus needs a target |
| Prev / Next | Listings: <a rel="prev|next"> when there is a URL, a disabled button when there is not. Dashboards: buttons | A control with a destination is a link. A control with none is not |
| Ellipsis | <span aria-hidden="true"> | A screen reader counting “dot dot dot” between 3 and 21 is noise |
| Summary | aria-live="polite" + aria-atomic="true" when the page changes in JS | A full navigation announces itself; a client-side swap does not |
| Target | 44px at md, 32px at sm | Same floor as Button. sm is desktop-pointer only |
| Disabled | disabled on buttons, aria-disabled on links, opacity .5 | The control stays in the tree so the layout does not jump |
Structure
A public listing, where each page has a URL. Prev is a disabled button because there is nowhere to go; Next is a link with rel="next".
<nav aria-label="Pagination">
<p aria-live="polite" aria-atomic="true">Showing 1–20 of 412</p>
<ul>
<li><button type="button" aria-label="Previous page" disabled><!-- lucide:chevron-left --></button></li>
<li><span aria-current="page"><span class="sr-only">Page </span>1</span></li>
<li><a href="?page=2"><span class="sr-only">Page </span>2</a></li>
<li><a href="?page=3"><span class="sr-only">Page </span>3</a></li>
<li><span aria-hidden="true">…</span></li>
<li><a href="?page=21"><span class="sr-only">Page </span>21</a></li>
<li><a rel="next" href="?page=2" aria-label="Next page"><!-- lucide:chevron-right --></a></li>
</ul>
</nav>The recipe in @sportingscouter/ui is pagination({ scale, layout }). pageLabel() is sr-only for the hidden “Page ” prefix. Page math ships as pure helpers: paginationWindow({ page, pageCount }) returns page numbers and ellipsis markers; paginationSlice({ page, pageSize, total }) returns from, to and pageCount for the summary — the “Showing 1–20 of 412” sentence stays in the app. After a client-side replace of the list, put focus back on the control that moved, or on the current page (tabindex="-1").
Rows-per-page is a Select composed beside this nav, not inside it — see Data table for the full composition.
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
scale | 'md' | 'sm' | 'md' | 'sm' is desktop-pointer only |
layout | 'full' | 'compact' | 'full' | Compact is Prev / status / Next. The caller swaps markup; the recipe does not hide numbers |
Rules
- Navigate, never commit. The current page is inverse, not the ink CTA. Blue is for links elsewhere on the page; these controls are ghost until selected.
- Keep the first and last page visible once the list is long enough to need an ellipsis. Losing page 1 is losing “back to the start”.
- One pagination per list. If a table and a card grid show the same result set, they share one nav. If compact and full both exist in the DOM, the hidden one carries
hidden, not just a display rule. - A destination is a link. Listings use
<a rel="prev|next">. Dashboards, with no URL, use buttons. Never a link to the page you are already on. - Restore focus after a JS page change. Replacing the list drops the focused node. Put it back, or move it to the results heading.
- Do not invent a page-size control here. Rows-per-page is a Select, composed beside this nav when a dashboard needs it.
- This is not the docs Previous/Next. The chrome at the bottom of every reference page is
.pagenav, labelled “Previous and next topic”.
theme.css.