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 anavigationlandmark. With more than one on a page, each needs an accessible name viaaria-labeloraria-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.
When to use links — and when buttons
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 | — |
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.
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.
Related
Permalink to "Related"- Announcing page changes — what to say after paging
- Page size selectors & range summaries — the controls next to pagination
- Sortable table in Vue — the table this component sits under