Pagination

A row of page numbers. It is a single class styling whatever is inside it, which makes it unusually flexible and means the semantics — which element, which attributes — are entirely up to you. That is where the care is needed.

When to use it

For a result set with a known size that people need to move around inside rather than read straight through — search results, an archive, a table of records. Page numbers let someone jump, come back, and link to where they were.

For a feed that is read in one direction and has no meaningful page boundaries, infinite scroll or a "load more" button is a better fit. Pagination exists to give people co-ordinates; if there is nothing to co-ordinate, it is overhead.

The current page is marked with aria-current, which is also what colours it
<nav class="pagination" aria-label="Search results"> <a href="#" aria-label="Previous page"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-left"></use></svg> </a> <a href="#">1</a> <a href="#">2</a> <a href="#" aria-current="page">3</a> <a href="#">4</a> <span aria-hidden="true">…</span> <a href="#">17</a> <a href="#" aria-label="Next page"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg> </a> </nav>
<nav class="pagination" aria-label="Search results">
  <a href="#" aria-label="Previous page">
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-left"></use></svg>
  </a>
  <a href="#">1</a>
  <a href="#">2</a>
  <a href="#" aria-current="page">3</a>
  <a href="#">4</a>
  <span aria-hidden="true">…</span>
  <a href="#">17</a>
  <a href="#" aria-label="Next page">
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg>
  </a>
</nav>

One rule, any child

The whole component is three declarations on the container and one on .pagination > *. Every direct child — link, button, span, whatever — gets the same square target, the same radius and tabular figures.

.pagination > * {
  min-inline-size: var(--control-h-sm);
  min-block-size: var(--control-h-sm);
  /* … centred, rounded, muted, tabular-nums … */
}
.pagination > a:hover { background: var(--surface-hover); color: var(--text); }
.pagination > [aria-current="page"] { background: var(--brand-600); … }

Two consequences worth naming. The hover state applies only to <a> — a <button> gets the shape but no hover, and you will need to add it. And the current-page style is keyed to [aria-current="page"] exactly, so aria-current="true" gets the layout and not the colour. That is stricter than .nav-link, which matches the bare attribute — an inconsistency between the two components rather than a bug in either.

font-variant-numeric: tabular-nums is why the numbers do not shuffle sideways as you page from 9 to 10. It is a small thing that is very visible when it is missing.

Which elements to use

Deck styles children and takes no view. The version that behaves best:

  • A <nav> with aria-label. Pagination is navigation, and a page often has two sets of it.
  • Links, not buttons — each page should have a URL someone can bookmark, share and open in a new tab.
  • The current page as a <span> rather than a link. A link to where you already are is a tab stop that does nothing. Keep aria-current="page" on it so it still looks and announces right.
  • Previous and next as real words in the accessible name. A chevron alone announces as nothing.
  • The ellipsis hidden. "…" is announced as "horizontal ellipsis" or as a pause, neither of which helps. aria-hidden="true".
On page one: previous is an inert span, not a disabled link
<nav class="pagination" aria-label="Invoices"> <span aria-hidden="true"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-left"></use></svg> </span> <span aria-current="page">1</span> <a href="#">2</a> <a href="#">3</a> <a href="#" aria-label="Next page"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg> </a> </nav>
<nav class="pagination" aria-label="Invoices">
  <span aria-hidden="true">
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-left"></use></svg>
  </span>
  <span aria-current="page">1</span>
  <a href="#">2</a>
  <a href="#">3</a>
  <a href="#" aria-label="Next page">
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg>
  </a>
</nav>

There is no disabled state in the component. A <span> is the honest version of "you cannot go back from here" — it is not focusable, so nobody tabs to a dead control, and it needs no aria-disabled to explain itself.

Classes

.pagination is a single class with no members — the styling is all on .pagination > * and attribute selectors — so there is no completeness check on this page.

Classes, generated from the stylesheet source
Class Layer What it does Source
.pagination deck.components Sets display, gap, align-items, flex-wrap. 07-components.css:481

Tokens

Design tokens this component reads
Token Live Declared value
--control-h-sm 34px
--space-1 .25rem
--space-2 .5rem
--text-sm .8125rem
--text-muted light-dark(var(--ink-600), var(--ink-400))
--surface-hover light-dark(var(--ink-50), var(--ink-900))
--brand-600 oklch(51.6% var(--chroma-brand) var(--hue-brand))
--text-on-brand oklch(99% .01 var(--hue-brand))
--r-xs 4px

Accessibility

  • Label the region. <nav aria-label="Search results">. Where pagination appears above and below the same list, either label them differently or mark the second aria-hidden and leave it out of the tab order — two identical navigation regions is a common and avoidable annoyance.
  • aria-current="page", exactly. It is both the announcement and the styling hook, so they cannot disagree — but note the selector wants that precise value.
  • The targets are 34px, not 44. --control-h-sm is below the --tap size Deck uses elsewhere. On a touch device, a row of 34px targets separated by a 4px gap is a genuinely difficult thing to hit. Raise it for touch, as in Overriding it.
  • Say where the reader is in text. "Page 3 of 17" above the row helps everybody and is the only version that survives on paper, where the whole component is removed.
  • Announce that the content changed. If pages load without a full navigation, the reader gets no signal. Move focus to the top of the results, or announce the change in a live region — the pagination cannot do it.
  • Numbers alone are poor names. "3" as a link name is thin out of context. aria-label="Page 3" costs one attribute and makes a links list readable.

Right to left

The row is flex, so it mirrors with the document and page 1 appears on the right — correct, since that is where reading starts. Nothing in src/19-logical.css is needed.

The chevrons are a different matter: they are directional icons, and they mirror through the icon rules rather than anything in this component. If you use a glyph or a text arrow instead of a Deck icon, it will not flip and "previous" will point the wrong way.

Page one on the right, chevrons flipped by the icon rules
<nav dir="rtl" class="pagination" aria-label="النتائج"> <a href="#" aria-label="الصفحة السابقة"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-left"></use></svg> </a> <a href="#">١</a> <a href="#" aria-current="page">٢</a> <a href="#">٣</a> <a href="#" aria-label="الصفحة التالية"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg> </a> </nav>
<nav dir="rtl" class="pagination" aria-label="النتائج">
  <a href="#" aria-label="الصفحة السابقة">
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-left"></use></svg>
  </a>
  <a href="#">١</a>
  <a href="#" aria-current="page">٢</a>
  <a href="#">٣</a>
  <a href="#" aria-label="الصفحة التالية">
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg>
  </a>
</nav>

Reduced motion

Nothing here is transitioned. The hover change on a link is instant by design, and there are no transforms or keyframes anywhere in the component — so prefers-reduced-motion has nothing to switch off.

Printing

.pagination is in the "chrome that should never print" list in src/99-print.css and is removed entirely. Page numbers you cannot click are noise, and the printed page has its own numbering.

This is the reason to write "Page 3 of 17" as text above the results rather than leaving the reader to infer it from the row. The text prints; the row does not.

Overriding it

@layer app.components {
  /* Full-size targets on touch devices */
  @media (pointer: coarse) {
    .pagination > * { min-inline-size: var(--tap); min-block-size: var(--tap); }
  }

  /* Give buttons the hover state only links get */
  .pagination > button:hover { background: var(--surface-hover); color: var(--text); }

  /* Outline the current page instead of filling it */
  .pagination > [aria-current="page"] {
    background: none;
    color: var(--brand);
    box-shadow: inset 0 0 0 1px var(--brand);
  }
}

When not to use it

  • Not for a feed. A stream with no stable page boundaries gives page numbers that mean something different every time they are loaded.
  • Not for three pages. Previous and next, or just showing everything, is less machinery than a numbered row.
  • Not for hundreds of pages. Nobody navigates to page 340 by clicking. If the set is that large, the reader needs filters and search, not co-ordinates.
  • Not without URLs. If pages do not have addresses, the reader cannot link to a result or use the back button, and pagination's main advantage is gone.
  • Not at small size on touch. 34px targets in a row are hard to hit — see Accessibility.