// pattern
Accessible Pagination Pattern
Page navigation built on a <nav> landmark with real links and aria-current="page". Works without JavaScript, survives page refresh, and announces the current page to screen readers.
// 01 · live demo
Live Demo
Press Tab to move through the page links. The current page is marked with aria-current="page".
Large range with ellipsis:
Compact prev/next only:
Inaccessible version (div-based, no landmark):
No <nav> landmark, no aria-current, icon-only arrows with no label, and fake buttons that require custom keyboard handlers. Middle-click won't open a page in a new tab.
// 02 · the code
The Code
<nav class="pagination" aria-label="Pagination">
<ul>
<li>
<a href="/results?page=2" rel="prev"
aria-label="Previous page">
<svg aria-hidden="true"><!-- chevron --></svg>
<span>Previous</span>
</a>
</li>
<li><a href="/results?page=1" aria-label="Page 1">1</a></li>
<li><a href="/results?page=2" aria-label="Page 2">2</a></li>
<li>
<a href="/results?page=3"
aria-current="page"
aria-label="Page 3, current page">3</a>
</li>
<li><a href="/results?page=4" aria-label="Page 4">4</a></li>
<li><a href="/results?page=5" aria-label="Page 5">5</a></li>
<li>
<a href="/results?page=4" rel="next"
aria-label="Next page">
<span>Next</span>
<svg aria-hidden="true"><!-- chevron --></svg>
</a>
</li>
</ul>
</nav>
<!-- With ellipsis for large ranges -->
<li><a href="/results?page=1">1</a></li>
<li><span aria-hidden="true">…</span></li>
<li><a href="/results?page=11">11</a></li>
.pagination {
display: flex;
justify-content: center;
}
.pagination ul {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
}
.pagination a {
display: inline-flex;
align-items: center;
justify-content: center;
/* SC 2.5.8: minimum target size */
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
color: #111827;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
}
.pagination a:hover {
background: #f8f9fa;
border-color: #6b7280;
}
/* Focus: SC 2.4.13 compliant */
.pagination a:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Visual cue for the current page,
driven by the same attribute that announces
it to screen readers */
.pagination a[aria-current="page"] {
background: var(--color-primary);
border-color: var(--color-primary);
color: #ffffff;
font-weight: 600;
}
.pagination .ellipsis {
color: #6b7280;
padding: 0 0.25rem;
}
href for progressive enhancement.
// 03 · why these decisions
Why These Decisions
Why wrap in a <nav> with aria-label?
Pagination is navigation. Wrapping it in a <nav> element creates a landmark that screen reader users can jump to with a single keystroke (for example, D in NVDA or the rotor in VoiceOver). The aria-label="Pagination" distinguishes it from other landmarks — main navigation, footer links, breadcrumbs — which are usually on the same page.
Why real links instead of <button> or <div>?
Pagination is navigation between resources. Each page should have a URL so users can bookmark, share, refresh, open in a new tab, or land on page 3 directly from a search result. Buttons work for in-page actions; divs aren't interactive at all. Use links.
Why aria-current="page" instead of class="active"?
A class only affects styling. aria-current="page" is announced by screen readers as "current page" — without it, a sighted user sees which page they're on but a screen reader user has to guess. The attribute is both machine-readable and a CSS hook ([aria-current="page"]), so you don't need a separate class.
Why add aria-label to each page link?
A link with just the text "3" is meaningless in a list of links (which is how screen readers often surface them). aria-label="Page 3" — and aria-label="Page 3, current page" for the active one — makes the link list self-explanatory.
Why use rel="prev" and rel="next"?
These hints communicate the sequential relationship between pages to browsers and some assistive tech. They're also useful for search engines on paginated content. They don't replace accessible labels — you still need aria-label="Previous page" — but they're a cheap addition that provides extra context.
Why make the ellipsis aria-hidden?
The ellipsis is purely visual — it indicates skipped page numbers. Announcing "ellipsis" or "horizontal ellipsis" adds noise without information. Use a <span aria-hidden="true"> so sighted users see it but screen readers skip past.
// 04 · keyboard interaction
Keyboard Interaction
| Key | Action |
|---|---|
| Tab | Moves focus to the next link (Previous, page numbers, Next). |
| Shift + Tab | Moves focus to the previous link. |
| Enter | Activates the focused link and navigates to that page. |
// 05 · wcag 2.2 success criteria
WCAG 2.2 Success Criteria
This pattern satisfies the following WCAG 2.2 success criteria:
-
1.3.1 Info and Relationships
Level A
— The
<nav>/<ul>/<li>structure programmatically conveys that the links are a list of navigation targets.aria-current="page"conveys the current page. - 2.1.1 Keyboard Level A — Real anchor elements are focusable and activatable via the keyboard with no custom code.
-
2.4.1 Bypass Blocks
Level A
— The
<nav>landmark allows screen reader users to jump to or past the pagination region. -
2.4.4 Link Purpose (In Context)
Level A
— Each link has a descriptive
aria-label(for example, "Page 3, current page") so its purpose is clear in isolation. -
2.4.8 Location
Level AAA
—
aria-current="page"tells the user where they are in the paginated set. - 2.4.13 Focus Appearance Level AAA — Links use a 2px outline with 3:1 contrast on focus.
- 2.5.8 Target Size (Minimum) Level AA — Each link meets the 24×24px minimum, with tap-friendly sizing at 2.75rem (44px) for touch.
-
4.1.2 Name, Role, Value
Level A
— Each anchor has a name (via text or
aria-label), a role (link), and the current page is communicated viaaria-current.
// 06 · screen reader behavior
Screen Reader Behavior
Entering the pagination landmark
- NVDA: "Pagination, navigation landmark, list with 7 items."
- JAWS: "Pagination, navigation region."
- VoiceOver: "Pagination, navigation" (in the rotor, also listed as a landmark).
Focused on the current page
- NVDA: "Page 3, current page, link."
- JAWS: "Page 3, current page, link."
- VoiceOver: "Page 3, current page, link."
Focused on Previous/Next
- "Previous page, link" — the full label is used instead of just the chevron.
- If the link is disabled on the first or last page, render it as a non-link
<span>(or omit it) rather than a disabled anchor — links cannot be "disabled" in a way that is accessible.
// 07 · common mistakes
Common Mistakes
<nav> landmark or aria-label
Without the landmark, screen reader users can't find or skip the pagination — they have to tab through every link. Without a label, a page with main nav, footer nav, breadcrumbs, and pagination all produces "navigation, navigation, navigation, navigation" with no way to tell them apart.
<a href><svg /></a> has no text for screen readers to announce. The user hears "link" with no indication of what it does. Add aria-label="Previous page" or include visible text.
aria-current
class="active" is purely visual. Screen reader users have no idea which page they're on. Use aria-current="page" — it's both the a11y signal and a CSS selector.
<div onclick> has no keyboard support, no middle-click, no context menu, no "open in new tab," and the screen reader treats it as plain text. Use an anchor with a real href.
<a> for Previous on page 1
Anchors don't have a disabled state. Visually dimming the link with CSS while leaving it clickable creates a broken experience. Either omit the link entirely on the first/last page or render it as a non-link <span> with the same styling.
aria-hidden="true" so it's visual only.