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

native-html minimal-aria wcag-2.2-aa

// 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):

1
2
3
4
5

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

HTML
<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">&hellip;</span></li>
<li><a href="/results?page=11">11</a></li>
CSS
.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;
}
No JavaScript required Real anchors to real URLs means the pattern works without JS, supports middle-click open-in-new-tab, and preserves your page-3 state if the user refreshes. If your app uses client-side routing, intercept the click in your framework but keep the real 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.
No custom key handling needed Because each page is a real link, the browser provides everything: tab focus, Enter to activate, context menu, open-in-new-tab. Don't reinvent this with custom keydown handlers on divs.

// 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 AAAaria-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 via aria-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

No <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.
Icon-only Previous/Next with no accessible name <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.
Using a class to mark the current page, not 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.
Clickable divs instead of anchors <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.
Disabled-looking <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.
Announcing the ellipsis Leaving the "…" accessible to screen readers means users hear "horizontal ellipsis" (or nothing, depending on the voice) between numbers. Mark it aria-hidden="true" so it's visual only.
Loading new results without announcement If your pagination is client-side and doesn't trigger a real page reload, users may not realize new results have loaded. Either navigate to a new URL (so the page title updates) or use a live region to announce the new result set.