Building an Accessible Pagination Component in Vue

Permalink to "Building an Accessible Pagination Component in Vue"

A pagination component is a small navigation region: a list of page controls, one of which is the current page. Done well, a screen reader user can find it from the landmarks list, hear which page they are on, jump to any page, and land somewhere sensible afterwards. Done badly — a row of unlabelled numbers and chevron icons in a <div> — it is a string of “button, button, 2, button, 3” with no indication of where the user is.

This page builds the component in Vue 3 with the Composition API. It is part of pagination & result-set navigation, and the post-change announcement it triggers follows announcing page changes in paginated tables.

Spec reference

Permalink to "Spec reference"
  • <nav> exposes a navigation landmark. With more than one on a page, each needs an accessible name via aria-label or aria-labelledby.
  • aria-current="page" (ARIA 1.2) marks the item representing the current page within a set. It is valid on links and buttons, and readers announce it as “current page”.
  • Links (<a href>) are correct when each page has a URL — server-rendered or router-backed pagination. Buttons are correct when paging only changes client state.

Criteria in play: SC 1.3.1 Info and Relationships (current page must be programmatic, not only bold), SC 2.4.4 Link Purpose (In Context) — “2” alone is weak; “Page 2” is clear — SC 4.1.2 for the controls, and SC 2.4.3 Focus Order for where focus lands after paging.

A pagination bar, annotated Mock of a pagination bar rendered as a row of cells showing previous, page numbers, an ellipsis, the current page and next, each numbered with its accessible treatment. A pagination bar, annotated‹ Prev11…36728…40Next ›1Previous: the chevron is aria-hidden and the accessible name is "Previous page"2The current page keeps its button and gains aria-current="page" — announced as "Page 7, current page"3Ellipsis is aria-hidden text, never a focusable control
Every control has a full name; the ellipsis is text, not a control.
Permalink to "When to use links — and when buttons"

Use links whenever the page number appears in the URL (?page=7). Users can open pages in new tabs, bookmark them, and the browser’s back button works. Vue Router’s <RouterLink> renders a real <a> and is the right primitive.

Use buttons when the table pages entirely in memory and the URL does not change. Then “Page 7” is an action, not a destination.

The misapplication to name is rendering the current page as a disabled button. disabled removes it from the accessibility tree’s focusable set and some readers skip it entirely, so users can hear pages 6 and 8 but not which page they are on. Keep the current page as a normal link or button with aria-current="page", or as plain text inside the list with the same attribute.

Annotated code example

Permalink to "Annotated code example"
<!-- TablePagination.vue -->
<script setup>
import { computed } from 'vue';
const props = defineProps({
  page: { type: Number, required: true },
  pageCount: { type: Number, required: true },
  label: { type: String, required: true },       // e.g. "Invoices pages"
});
const emit = defineEmits(['change']);

// 1 … 6 7 8 … 40 — truncated window around the current page
const items = computed(() => {
  const { page, pageCount } = props;
  const set = new Set([1, pageCount, page - 1, page, page + 1]);
  const pages = [...set].filter((p) => p >= 1 && p <= pageCount).sort((a, b) => a - b);
  const out = [];
  pages.forEach((p, i) => {
    if (i && p - pages[i - 1] > 1) out.push({ gap: true, key: `gap-${p}` });
    out.push({ page: p, key: p });
  });
  return out;
});
</script>

<template>
  <!-- SC 1.3.1: a named landmark, findable from the landmarks list -->
  <nav :aria-label="label" class="pagination">
    <ul>
      <li>
        <!-- SC 2.4.4 + 4.1.2: full name; icon hidden -->
        <button type="button" :disabled="page === 1" @click="emit('change', page - 1)">
          <span aria-hidden="true">‹</span> Previous<span class="visually-hidden"> page</span>
        </button>
      </li>
      <li v-for="it in items" :key="it.key">
        <!-- SC 1.3.1: truncation is text, hidden from AT; the numbers around it suffice -->
        <span v-if="it.gap" aria-hidden="true" class="gap">…</span>
        <button v-else type="button"
                :aria-current="it.page === page ? 'page' : undefined"
                @click="emit('change', it.page)">
          <span class="visually-hidden">Page </span>{{ it.page }}
        </button>
      </li>
      <li>
        <button type="button" :disabled="page === pageCount" @click="emit('change', page + 1)">
          Next<span class="visually-hidden"> page</span> <span aria-hidden="true">›</span>
        </button>
      </li>
    </ul>
  </nav>
</template>
<!-- Parent: move focus and announce after the new rows render -->
<script setup>
import { nextTick, ref } from 'vue';
const caption = ref(null);
const status = ref('');
async function goTo(p) {
  page.value = p;
  await loadPage(p);
  await nextTick();                                  // rows are in the DOM now
  caption.value.focus();                             // SC 2.4.3: top of the new page
  status.value = `Page ${p} of ${pageCount.value}, showing ${rangeText.value}.`;  // SC 4.1.3
}
</script>
<!-- <caption ref="caption" tabindex="-1">Invoices</caption> and <p role="status">{{ status }}</p> -->

Disabling Previous on the first page is acceptable — unlike the current page, a disabled Previous carries no information the user needs. If you prefer it discoverable, use aria-disabled="true" and a click guard.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Key / event Expected announcement AT-specific deviations
D / landmarks list (NVDA) “Invoices pages, navigation landmark” VoiceOver rotor lists it under Landmarks by name
Tab to page 7 “Page 7, current page, button” JAWS: “Page 7, button, current page”
Tab to page 8 “Page 8, button” —
Enter on page 8 Focus to caption: “Invoices, table” then “Page 8 of 40, showing 176 to 200 of 1,000.” VoiceOver may read the caption only; the status follows
Tab over the ellipsis Not reached; hidden —
A page change, end to end Flow from activating a page control through loading data, rendering rows, moving focus to the caption and announcing the new range. A page change, end to endActivate "Page8"button or linkLoad datarequest or slicenextTicknew rows in theDOMFocus captiontabindex="-1", topof tableAnnounce range"Page 8 of 40,showing 176 to200"
Focus goes to the top of the new content — never back to the pagination bar at the bottom.

Integration context

Permalink to "Integration context"

Pagination usually sits next to a page-size selector and a range summary (“Showing 176–200 of 1,000”); those controls and their interaction with this component are covered in page size selectors and result range summaries. If you want keyboard shortcuts for next and previous page, add them as described in keyboard shortcuts for result page navigation, and document them.

The table above the pagination can be the Vue sortable table from building an accessible sortable table in Vue; sort changes should reset to page 1 and say so.

Links or buttons for page controls? Decision tree choosing between router links and buttons for pagination based on whether the page number is part of the URL. Links or buttons for page controls?Does each page have its own URL, such as ?page=7?YesRouterLink or a with hrefbookmarkable; back button worksNobutton type="button"paging is an action on local state
The URL decides: if a page can be bookmarked, its control is a link.

Gotchas

Permalink to "Gotchas"

Two pagination bars, one label. Tables often render pagination above and below. Two nav elements with the same name are confusing in the landmarks list; label them “Invoices pages, top” and “…, bottom”, or render only one.

Focus left on a removed button. If the page window shifts and the button that had focus is no longer rendered, focus drops to body. Moving focus to the caption avoids the problem.

Screen-reader-only “Page” prefix and zoom. Visually hidden text is fine for this; do not use aria-label="Page 7" on a button whose visible text is “7” — it works, but breaks SC 2.5.3 Label in Name for speech-input users if the label does not start with the visible text. “Page 7” contains “7”, so it passes; keep it that way.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
Should the pagination component announce the page change itself?

It is cleaner for the component to emit the change and let the table own the announcement, because only the table knows when the new rows have rendered and what range they cover. The component’s job is correct names and the current-page state.

Should pagination use links or buttons?

Links when each page has its own URL, because users can bookmark, share and open pages in new tabs. Buttons when paging only changes client-side state and the URL stays the same.

How do I mark the current page in accessible pagination?

Add aria-current=“page” to the control for the current page and keep it in the list. Do not disable it — disabled controls are skipped by some screen readers, so users lose track of where they are.

Where should focus go after changing page?

To the top of the new content, usually the table caption made focusable with tabindex=“-1”, followed by a status message with the page number and range. Leaving focus on the pagination bar forces users to navigate back up to reach the data.

Permalink to "Related"

← Back to Pagination & Result-Set Navigation