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.
<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>witharia-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. Keeparia-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".
<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.
| Class | Layer | What it does | Source |
|---|---|---|---|
.pagination |
deck.components |
Sets display, gap, align-items, flex-wrap. | 07-components.css:481 |
Tokens
| 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 secondaria-hiddenand 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-smis below the--tapsize 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.
<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.