Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

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.

Showing 20 of 68

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.

after=evt_8fK…

No page numbers, because there is no stable page 7 in a feed that gains rows while you read it. Cursors trade the ability to jump for never showing a duplicate or skipping a record.

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.

With context
Showing 76–100 of 1,284
Without

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.

First page
Middle
Last page
Few pages
Single page
With total
Load more
Loading
Page size
Cursor

Anatomy

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.

  1. 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?".

  2. 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.

  3. 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.

  4. EllipsisStatic, aria-hidden

    Not a button. Making it clickable to expand hidden pages is a target that behaves differently from every one beside it.

  5. 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.

  6. 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.

  7. 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.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
--space-0-5Gap between page numbers
--space-2Gap to Prev and Next

Radius

TokenValueUsed for
--radius-mdPage target corners

Typography

TokenValueUsed for
--text-label-smPage numbers, tabular figures

Motion

TokenValueUsed for
--duration-fastHover transition

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightLabel gapTypeMin widthTouch targetWhen to use
Small28px2px12px28px—Inside a card or a compact table footer.
Medium32px2px12px32px44px on coarse pointersThe default.
Range summary——12px——Left of the controls on desktop, above them on mobile.
Page size select32px——7rem—10 / 25 / 50 / 100. Persist the choice — it is a preference, not a per-visit decision.
Load more36px——10rem—Centred under the list, with the loaded-of-total count beneath it.

Do

/deployments?page=4&size=25
Put the page in the URLA page number that vanishes on refresh is not a position. Bookmarking, sharing and browser back are most of what pagination is for over infinite scroll.
Showing 76–100 of 1,284
Show the range, not just the page"76–100 of 1,284" tells the user how big the set is and whether their filter did anything. "Page 4" tells them almost nothing.
const first = (page − 1) × oldSize
setPage(⌊first / newSize⌋ + 1)
Keep the user near the same records when the page size changesJumping to page 1 because they switched from 25 to 50 loses their place. Recompute the page from the first visible record instead.
results.focus()
announce(`Showing 76–100 of 1,284`)
Move focus to the results, not the pagerAfter a page change the user wants the new rows. Focus the results region and announce the range, or a keyboard user is left at the bottom with no idea anything happened.

Don't

12345678910111213141516171819202122232425262728293031323334353637383940
Do not render every page numberFive hundred targets is not navigation. Truncation keeps the control a fixed width, so Next stays where the pointer expects it on every page.
…footer unreachable
Do not use infinite scroll for a working listIt breaks the footer, breaks browser back, and makes a position impossible to bookmark or describe. Fine for a feed; wrong for anything a user works through.
1 2 3 →vs← 1 2 3 →
Do not remove Prev and Next at the endsRemoving them re-flows every number sideways at exactly the moment the user has arrived at page 1 or the last page. Disable them in place.
Page 40 → “after=?” → there is no cursor for page 40
Do not offer page numbers over a cursor APIThe control promises a jump to page 40 that the backend cannot deliver, and the first time someone tries it the product looks broken rather than the API.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA2.4.3Focus OrderA2.4.8LocationAAA2.5.8Target Size (Minimum)AA4.1.3Status MessagesAA

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

TabStops on Prev, each rendered page, then Next. Truncation is what keeps that from being fifty stops.
Enter / SpaceNavigates 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 / EndOn 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.
AttributeApplied toNotes
role="navigation"The containerWith aria-label="Pagination". It is a landmark, so it can be jumped to and skipped.
aria-current="page"The current pageThe current page is not a link. This is what announces "you are here" rather than "go here".
aria-labelEach 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 summaryAnnounces "Showing 76 to 100 of 1,284" after a page change, which is the only feedback a screen-reader user gets.
aria-disabledPrev on page 1, Next on the lastKeeps them in the tab order and in place, so nothing shifts at the ends of the set.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
siblingsnumber1Pages shown either side of the current one. 0 on mobile, 2 on wide tables.
totalItemsnumber—Enables the "76–100 of 1,284" summary. Supply it whenever the count is knowable.
pageSizenumber—Needed with totalItems to compute the displayed range.

Notes

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.