Pagination
Pagination is a set of page numbers that lets someone jump between the pages of a longer list, such as search results or a data table, with first, previous, next, and last controls, and collapsing gaps once there are too many pages to show at once.
It's fully controlled and framework-agnostic by design: every control is a plain button, never a real link, so there's no native navigation for an SPA router to intercept in the first place. Activating one just requests the page, and pagination never updates its own current page in response. Applying the change, such as setting the page back, fetching the new page's data, or updating a query string through your router, stays entirely your own responsibility (see "SPA and router integration" in the Examples tab).
Dependencies
When to use
Add pagination once a list is too long to show all at once and you'd
rather split it into discrete pages than load everything, or scroll
endlessly, such as search results, a data table, or an admin listing.
Prefer
Install & usage
Pick a framework in the toolbar above and these snippets adapt.
npm install @eunomia/elements
import "@eunomia/elements/pagination.js";
<eun-pagination
page="1"
total-pages="12"
aria-label="Pagination"
></eun-pagination>
<script>
document
.querySelector("eun-pagination")
.addEventListener("eunpagechange", (event) => {
// Apply the change yourself : fetch the new page, update the URL, ...
event.target.page = event.page;
});
</script>
Importing the file registers <eun-pagination> as a custom element, with no
further setup needed. It works with any framework, or none, since it's a
standard web component. page is entirely yours to own: eun-pagination
only tells you which page was requested through eunpagechange, it never
sets page back itself (see the API tab).
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/pagination.js";
</script>
<eun-pagination
page="1"
total-pages="12"
aria-label="Pagination"
></eun-pagination>
<script type="module">
const pagination = document.querySelector("eun-pagination");
pagination.addEventListener("eunpagechange", (event) => {
pagination.page = event.page;
// ... fetch/render the new page's data
});
</script>
npm install @eunomia/elements
import { useState } from "react";
import "@eunomia/elements/pagination.js";
function ResultsPagination({ totalPages }) {
const [page, setPage] = useState(1);
return (
<eun-pagination
page={page}
total-pages={totalPages}
aria-label="Pagination"
oneunpagechange={(event) => setPage(event.page)}
/>
);
}
npm install @eunomia/elements
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import "@eunomia/elements/pagination.js";
export function ResultsPagination({ totalPages }) {
const router = useRouter();
const searchParams = useSearchParams();
const page = Number(searchParams.get("page") ?? 1);
return (
<eun-pagination
page={page}
total-pages={totalPages}
aria-label="Pagination"
oneunpagechange={(event) => {
const params = new URLSearchParams(searchParams);
params.set("page", String(event.page));
router.push(`?${params.toString()}`);
}}
/>
);
}
npm install @eunomia/elements
<script setup>
import { ref } from "vue";
import "@eunomia/elements/pagination.js";
const page = ref(1);
</script>
<template>
<eun-pagination
:page="page"
total-pages="12"
aria-label="Pagination"
@eunpagechange="page = $event.page"
/>
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/pagination.js";
@Component({
selector: "app-results-pagination",
template: `
<eun-pagination
[page]="page"
total-pages="12"
aria-label="Pagination"
(eunpagechange)="page = $event.page"
></eun-pagination>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class ResultsPaginationComponent {
page = 1;
}
Alternatives
eun-infinite-listGuidance
- Set
pageback to the requested value once you've actually applied it (fetched the data, updated the URL, ...), not optimistically before that, so the UI never shows a page whose content hasn't loaded yet - Set
disabledwhile the requested page's data is loading, so a second click can't race the first (see the Examples tab) - Keep
totalPagesin sync with your actual data, sinceeun-paginationhas no way to know it on its own - Leave
siblingCount/boundaryCountat their defaults unless you have a specific reason to show more or fewer page numbers, since the defaults already match common pagination conventions - Recompute
totalPagesand resetpageto1when applying aeunpagesizechange, since the old page position no longer means anything once the page size changes (see the Examples tab)
- Expecting
pageto update itself after a click: it never does, on purpose (see "Fully controlled" above) - Using it for a handful of items that fit on one page: a two-page list rarely earns a full pagination control
- Reaching for it when "keep loading more" fits better: see Alternatives above
Live testing
Properties
page and totalPages are the two you'll always set. Everything else
tunes the collapsed-page-range algorithm, hides controls, shows a visible
label next to First/Previous/Next/Last (showLabels), relabels controls
for another language, or adds a page-size selector (pageSizeOptions, see
the Examples tab).
Pagination <eun-pagination>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| total-pages | number | 1 | The total number of pages |
| sibling-count | number | 1 | How many page numbers to show on each side of the current page before collapsing the rest into an ellipsis gap |
| boundary-count | number | 1 | How many page numbers to always show at the very start and end |
| hide-prev-next | boolean | false | Hides the previous and next controls |
| hide-first-last | boolean | false | Hides the first and last controls |
| show-labels | boolean | false | Shows a visible text label next to the first, previous, next, and last icons, instead of icon-only controls |
| page-size | number | — | The current page size, in items per page. Only meaningful once pageSizeOptions is also set |
| page-size-options | Array<number> | [] | The selectable page sizes. Setting this renders a page-size selector alongside the page controls. Left empty, the default, no selector is rendered at all |
| page-size-label | string | 'Items per page' | The label for the page-size selector |
| aria-label-previous | string | 'Previous page' | The accessible label of the previous control |
| aria-label-next | string | 'Next page' | The accessible label of the next control |
| aria-label-first | string | 'First page' | The accessible label of the first control |
| aria-label-last | string | 'Last page' | The accessible label of the last control |
| aria-label-page | string | 'Page' | The accessible label prefix applied to every page number control, such as Page 3 |
| aria-label | string | 'Pagination' | The accessible label for the navigation landmark |
| page | number | 1 | The current page, starting at 1 |
| disabled | boolean | false | Disables every control, such as while the current page's data is loading |
Import the exact TypeScript type behind any property above, see
Events
| Name | Type | Description |
|---|---|---|
| eunpagechange | PageChangeEvent | Fired with the requested page whenever a control is activated. Page is never updated internally |
| eunpagesizechange | PageSizeChangeEvent | Fired with the chosen value whenever the page-size selector changes. PageSize is never updated internally |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --pagination-gap | Sets the gap between controls |
| --pagination-item-size | Sets the width and height of every control |
| --pagination-border-radius | Sets the corner radius of every control |
| --pagination-item-color | Sets the text and icon color of non-current controls |
| --pagination-item-color-hover | Sets the text and icon color of non-current controls on hover |
| --pagination-item-background-hover | Sets the background color of page-number controls on hover |
| --pagination-control-background | Sets the background color of the first, previous, next, and last controls, distinguishing them from page numbers |
| --pagination-control-background-hover | Sets the background color of the first, previous, next, and last controls on hover |
| --pagination-current-background | Sets the background color of the current page |
| --pagination-current-color | Sets the text color of the current page |
| --pagination-disabled-color | Sets the text and icon color of disabled controls |
| --pagination-ellipsis-color | Sets the color of the ellipsis |
| --pagination-focus-outline-color | Sets the color of a control's focus outline |
| --pagination-font-size | Sets the font size of every control |
| --pagination-transition-duration | Sets the duration of the hover color and background transition |
Basic
Few enough pages that every number fits, so no … gap is needed.
<eun-pagination
page="2"
total-pages="5"
aria-label="Pagination"
></eun-pagination>
Collapsed range
Past a certain page count, the middle collapses into … gaps on one or
both sides of the current page, always keeping the first/last
boundaryCount pages and the current page's siblingCount neighbors
visible.
<eun-pagination
page="10"
total-pages="30"
aria-label="Pagination"
></eun-pagination>
Custom sibling and boundary count
<eun-pagination
page="10"
total-pages="30"
sibling-count="2"
boundary-count="2"
aria-label="Pagination"
></eun-pagination>
Hiding controls
Hide First/Last when jumping straight to either end rarely matters, or
Previous/Next when the page numbers alone are enough.
<eun-pagination
page="10"
total-pages="30"
hide-first-last
aria-label="Pagination"
></eun-pagination>
Labeled controls
showLabels adds a visible text label next to the First/Previous/Next/Last
icons, the same strings as their aria-label-*, for a more explicit,
less icon-reliant control. The icon always points away from its label (see
the Accessibility tab).
<eun-pagination
page="4"
total-pages="12"
show-labels
aria-label="Pagination"
></eun-pagination>
Disabled while loading
Set disabled while the requested page's data is in flight, so a second
click can't race the first. See "SPA and router integration" below for
the full pattern.
<eun-pagination
page="4"
total-pages="12"
disabled
aria-label="Pagination"
></eun-pagination>
Custom
Override the --pagination-* CSS variables, listed in full in the API tab.
<eun-pagination
class="custom-pagination"
page="4"
total-pages="12"
aria-label="Pagination"
></eun-pagination>
.custom-pagination {
--pagination-current-background: #be185d;
--pagination-item-background-hover: #fce7f3;
--pagination-border-radius: 999px;
}
Page-size selector
Setting pageSizeOptions renders a eun-select-powered page-size selector
alongside the page controls, labeled with pageSizeLabel. Left unset (the
default), no selector is rendered at all, since eun-pagination has no
page size of its own to offer choices for. Like eunpagechange,
eunpagesizechange is a pure request: pageSize is never updated
internally, and neither is page/totalPages, since changing how many
items fit per page almost always changes the total page count too, which
only you know how to recompute.
import "@eunomia/elements/pagination.js";
import "@eunomia/elements/select.js"; // powers the page-size selector
<eun-pagination
page="1"
total-pages="10"
page-size="10"
page-size-options="[10, 25, 50]"
aria-label="Pagination"
></eun-pagination>
const pagination = document.querySelector("eun-pagination");
pagination.addEventListener("eunpagesizechange", (event) => {
const totalItems = 97; // however you know your actual total is
pagination.pageSize = event.pageSize;
pagination.totalPages = Math.ceil(totalItems / event.pageSize);
pagination.page = 1; // the old page position no longer means anything
});
SPA and router integration
eun-pagination never touches the URL or your router itself. It only
tells you which page was requested. The pattern is always the same,
regardless of framework: listen for eunpagechange, apply the change
however fits your app, then set page back once it's actually done.
const pagination = document.querySelector("eun-pagination");
pagination.addEventListener("eunpagechange", async (event) => {
pagination.disabled = true; // block a second click while this one's in flight
await goToPage(event.page); // your own router push / fetch / state update
pagination.page = event.page;
pagination.disabled = false;
});
Keyboard interactions
eun-pagination adds no custom keyboard handling of its own, since every
control is a native <button> (or, for the current page, plain
non-interactive text), so standard button/focus behavior already applies.
Unlike a listbox or tablist, there's no dedicated "pagination" pattern in
the WAI-ARIA APG requiring roving tabindex or arrow-key navigation between
controls, so this deliberately stays a plain sequence of independent
buttons rather than a composite widget:
| Key | Action |
|---|---|
Tab / Shift+Tab |
Moves focus between controls, in visual order |
Enter / Space |
Activates the focused control |
Aria attributes and rules
- Rendered as
<nav aria-label="Pagination">wrapping a<ul role="list">, following the same landmark-plus-list structure aseun-breadcrumb. Overridearia-labelfor other languages/contexts. role="list"is set explicitly despite<ul>having it implicitly: Safari drops the implicit role oncelist-style: noneis applied (needed here for the visual flex layout), which would otherwise silently skip the list for VoiceOver users.- The current page renders as a non-interactive
<span aria-current="page">, never a button, mirroringeun-breadcrumb's current step, since activating the page someone's already on is a confusing, redundant control. First/Previous/Next/Lastand every page-number control carry an explicitaria-label(aria-label-first/aria-label-previous/aria-label-next/aria-label-last/aria-label-page), since a lone chevron icon or a bare digit has no accessible name on its own otherwise.- Unreachable controls (
Previous/Firston page 1,Next/Laston the last page, or every control whiledisabledis set) use the nativedisabledattribute, notaria-disabled, so they're properly excluded from the tab order and announced as unavailable by assistive technology, not just visually dimmed. …gaps are rendered as<span aria-hidden="true">…</span>: a purely visual indicator of skipped pages, not a control. There's nothing to activate, so it's excluded from the accessibility tree entirely rather than being a dead, focusable stop.- While
showLabelsis set, First/Previous/Next/Last drop theiraria-labelentirely rather than keeping it alongside the now-visible text, so the button's accessible name comes from its content instead, avoiding two competing sources of truth for the same name, and keeping the visible label and accessible name identical (WCAG 2.5.3, Label in Name).eun-iconstaysaria-hiddeneither way, so it never contributes to that name. - The page-size selector (
pageSizeOptions, see the Examples tab) is aeun-selectfield, given its own accessible name throughpageSizeLabel, so no extra ARIA is needed on top of what it already provides on its own (see the Select component).
No real links, on purpose
Every control is a <button>, never an <a href>. Unlike eun-button/
eun-link/eun-breadcrumb-item, eun-pagination never performs (or lets a
router intercept) a native navigation at all. This is deliberate: pages
are almost always fetched or rendered client-side rather than served from
a distinct URL per page, so there's no href to meaningfully build in the
first place. If your pages are individually linkable (e.g. server-rendered
?page=N results), keep the URL in sync yourself from eunpagechange (see
"SPA and router integration" in the Examples tab) rather than expecting
eun-pagination to manage it.
Reference links