Pagination
Splitting a long result set into pages a user can return to. The real question is never "how many per page" — it is whether the user needs to get back to where they were.
Also called Pager, Load More, Infinite Scroll — in this system all of them are Pagination.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Load more
The honest middle ground. The user decides when to fetch, the footer stays reachable, and the count tells them how much is left — which is exactly what infinite scroll hides.
Cursor pagination
For sets that change while you read them. There are no page numbers because there is no stable page 7 — offering one would be a promise the API cannot keep.
Truncation keeps the width fixed
First, last, current and its neighbours. The control is the same width on page 2 and page 47, so the Next button never moves out from under the pointer.
Always state the range
"1–25 of 1,284" answers three questions a page number cannot: how big the set is, how far in you are, and whether the filter did anything.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Range summary, previous, truncated page numbers with the current one marked, and next. The width does not change as the page does.
- Range summary"76–100 of 1,284"
Placed before the controls, because it is the answer to "did my filter work?" and that question comes before "where do I go next?".
- Target size32 × 32px
Square, so a one-digit and a three-digit page are the same target. Ragged widths make the row jitter as the numbers grow.
- Current pageAccent fill + aria-current
Not a link. It is where you already are, so it is not a target — and aria-current="page" is what tells a screen reader that.
- EllipsisStatic, aria-hidden
Not a button. Making it clickable to expand hidden pages is a target that behaves differently from every one beside it.
- Siblings1 either side
One neighbour covers "the next one" and "the one I just left". Two is defensible on wide tables; zero makes the control read as broken.
- Prev / Next32px, disabled at the ends
Disabled rather than removed. Removing them shifts every number sideways at exactly the moment the user reaches the first or last page.
- Gap2px between numbers
Tight, so the run of numbers reads as one control. The gap to Prev and Next is 8px, marking them as a different kind of action.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-fg-secondary | — | Idle page numbers |
| --ds-layer-hover | — | Hover fill on a page |
| --ds-accent | — | Current page fill |
| --ds-fg-on-accent | — | Current page number |
| --ds-fg-disabled | — | Ellipsis and disabled arrows — both non-targets |
| --ds-focus-ring | — | Focus outline |
Spacing
| Token | Value | Used for |
|---|---|---|
| --space-0-5 | Gap between page numbers | |
| --space-2 | Gap to Prev and Next |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | Page target corners |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-label-sm | Page numbers, tabular figures |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-fast | Hover transition |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Label gap | Type | Min width | Touch target | When to use |
|---|---|---|---|---|---|---|
| Small | 28px | 2px | 12px | 28px | — | Inside a card or a compact table footer. |
| Medium | 32px | 2px | 12px | 32px | 44px on coarse pointers | The default. |
| Range summary | — | — | 12px | — | — | Left of the controls on desktop, above them on mobile. |
| Page size select | 32px | — | — | 7rem | — | 10 / 25 / 50 / 100. Persist the choice — it is a preference, not a per-visit decision. |
| Load more | 36px | — | — | 10rem | — | Centred under the list, with the loaded-of-total count beneath it. |
/deployments?page=4&size=25const first = (page − 1) × oldSize
setPage(⌊first / newSize⌋ + 1)results.focus()
announce(`Showing 76–100 of 1,284`)Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The current page must be distinguishable from the rest by more than a tint — it carries a fill and an inverted label, so the state survives greyscale.
- Disabled arrows may use the disabled tone; they are not targets. Idle page numbers are targets and owe 4.5:1.
- The ellipsis is decorative and aria-hidden, so it is exempt — which is only true because it is genuinely not interactive.
Keyboard
| Tab | Stops on Prev, each rendered page, then Next. Truncation is what keeps that from being fifty stops. |
| Enter / Space | Navigates to that page. |
| ← / → | Optional shortcut for previous and next when focus is inside the results region — a genuine accelerator for scanning a long set. |
| Home / End | On a focused pager, jumps to the first or last page. |
Screen readers
- The landmark announces as "Pagination, navigation", so it can be skipped in one gesture.
- The live region must fire after the new results land, not when the request starts, or the announced range is the old one.
- For load-more, announce the delta as well as the total: "20 more loaded, 40 of 68 showing".
Focus & touch
- After a page change, move focus to the results region — with tabindex="-1" — not back to the pager. The user asked for new rows, so put them at the top of the reading order. Never leave focus on a button that has just become disabled.
- Page targets grow to 44px on coarse pointers, which usually means dropping to zero siblings and showing only Prev, the current page, and Next. Numeric pagination is a desktop pattern; on a phone, load-more with a visible count is almost always the better control.
| Attribute | Applied to | Notes |
|---|---|---|
| role="navigation" | The container | With aria-label="Pagination". It is a landmark, so it can be jumped to and skipped. |
| aria-current="page" | The current page | The current page is not a link. This is what announces "you are here" rather than "go here". |
| aria-label | Each page target | "Go to page 4". A bare "4" announces as a number with no indication of what it does. |
| aria-live="polite" | The range summary | Announces "Showing 76 to 100 of 1,284" after a page change, which is the only feedback a screen-reader user gets. |
| aria-disabled | Prev on page 1, Next on the last | Keeps them in the tab order and in place, so nothing shifts at the ends of the set. |
Example usage
1import { Pagination } from '@/ui/Navigation'23// The page lives in the URL. That is what makes it a position rather than4// a piece of component state that dies on refresh.5const [params, setParams] = useSearchParams()6const page = Number(params.get('page') ?? 1)7const size = Number(params.get('size') ?? 25)89<Pagination10 page={page}11 pageCount={Math.ceil(total / size)}12 totalItems={total}13 pageSize={size}14 onPageChange={(p) => {15 setParams({ page: String(p), size: String(size) })16 // The user asked for rows, not for the pager. Send them to the rows.17 resultsRef.current?.focus()18 }}19/>2021// Changing page size must not lose the user's place.22function onPageSizeChange(next: number) {23 const firstRecord = (page - 1) * size24 setParams({ page: String(Math.floor(firstRecord / next) + 1), size: String(next) })25}2627// Cursor pagination: no page numbers, because there is no stable page 7.28<Row>29 <Button disabled={!before} onClick={() => fetchPage({ before })}>Previous</Button>30 <Button disabled={!after} onClick={() => fetchPage({ after })}>Next</Button>31</Row>Framework-free HTML
<nav role="navigation" aria-label="Pagination">
<p class="ds-pager__summary">Showing 76–100 of 1,284</p>
<a href="?page=3" aria-label="Go to previous page" rel="prev">
<svg aria-hidden="true">…</svg>
</a>
<a href="?page=1" aria-label="Go to page 1">1</a>
<span aria-hidden="true">…</span>
<a href="?page=3" aria-label="Go to page 3">3</a>
<!-- Where you are is not somewhere to go. -->
<span aria-current="page">4</span>
<a href="?page=5" aria-label="Go to page 5">5</a>
<span aria-hidden="true">…</span>
<a href="?page=52" aria-label="Go to page 52">52</a>
<a href="?page=5" aria-label="Go to next page" rel="next">
<svg aria-hidden="true">…</svg>
</a>
</nav>
<p class="sr-only" role="status" aria-live="polite">Showing 76 to 100 of 1,284</p>CSS
.ds-pager {
display: flex;
align-items: center;
gap: 2px; /* the run of numbers is one control */
}
.ds-pager__nav {
margin-inline: 6px; /* 8px total — Prev/Next are a different job */
}
/* Square, so a 1 and a 400 are the same target and the row never jitters. */
.ds-pager a,
.ds-pager [aria-current] {
min-inline-size: 32px;
block-size: 32px;
display: grid;
place-items: center;
border-radius: var(--radius-md);
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--ds-fg-secondary);
}
.ds-pager a:hover { background: var(--ds-layer-hover); color: var(--ds-fg); }
.ds-pager [aria-current='page'] {
background: var(--ds-accent);
color: var(--ds-fg-on-accent);
cursor: default; /* it is not a destination */
}
/* Disabled, never removed — removing shifts every number at the set's ends. */
.ds-pager [aria-disabled='true'] {
color: var(--ds-fg-disabled);
pointer-events: none;
}
@media (pointer: coarse) {
.ds-pager a,
.ds-pager [aria-current] { min-inline-size: 44px; block-size: 44px; }
/* Numeric paging is a desktop pattern. Drop to Prev / current / Next. */
.ds-pager__sibling { display: none; }
}Component API
Pagination
| Prop | Type | Default | Description |
|---|---|---|---|
| page* | number | — | 1-indexed current page. Should be read from the URL, not from local state. |
| pageCount* | number | — | Total pages. Renders nothing when this is 1. |
| onPageChange* | (page: number) => void | — | Update the URL here, and move focus to the results. |
| siblings | number | 1 | Pages shown either side of the current one. 0 on mobile, 2 on wide tables. |
| totalItems | number | — | Enables the "76–100 of 1,284" summary. Supply it whenever the count is knowable. |
| pageSize | number | — | Needed with totalItems to compute the displayed range. |
Professional tips
- Default to 25 rows. It fills a laptop viewport without scrolling and keeps the response small enough to feel instant.
- Persist the page-size choice per user, not per visit. It is a preference about how someone likes to work.
- Prefetch the next page on hover over Next. It is a cheap request and it makes the most common navigation feel instant.
- Show a skeleton in the existing rows rather than emptying the table. A table that goes blank between pages reads as an error.
- Reset to page 1 when a filter changes — but say so, because silently jumping is disorienting when the user was on page 12.
Performance
- Offset pagination gets slower as the offset grows: OFFSET 100000 makes the database walk 100,000 rows. Cursors are constant-time, which is the real reason large systems use them.
- Cache the total count separately and refresh it less often. COUNT(*) over a filtered set is frequently more expensive than the page query itself.
- Prefetch exactly one page ahead. Prefetching five is a lot of wasted work for a user who was going to stop at page 2.
- Keep the previous page rendered while the next loads, and swap in one commit. A blank frame between pages is perceived as slower than the same wait with stale rows.
Common mistakes
- Page state in the component rather than the URL, so refresh and back both lose the position.
- Rendering every page number, producing hundreds of tab stops and a control whose width changes constantly.
- Resetting to page 1 on a page-size change, losing the user’s place for no reason.
- Leaving focus on Next after it becomes disabled on the last page.
- Page numbers over a cursor API, promising a jump the backend cannot perform.
- No live region, so a screen-reader user presses Next and hears nothing at all.
- Infinite scroll on a list with a footer, making the footer permanently unreachable.
Real-world recommendations
- Most users never leave page 1. If yours do routinely, the problem is ranking or filtering, not pagination — fix the thing that made page 4 necessary.
- Search results are the one place where jumping deep is common, which is why they are the strongest case for numbered pages and a visible total.
- Feeds are the one place infinite scroll is genuinely right, and even there a "back to top" control after a few screens is worth more than it costs.
- Load-more consistently outperforms both in usability testing for working lists: the user controls the fetch, the position survives, and the footer stays reachable.