Eunomia v1.1.0
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

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

eun-icon eun-select · if pageSizeOptions is set
Overview API Examples Accessibility

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 infinite list instead when "keep loading more as you scroll" fits the content better than discrete, addressable pages. Reach for pagination when someone benefits from jumping straight to a specific page, sharing a link to it, or seeing roughly how much content there is in total. See Alternatives below for a side by side comparison.

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

Alternatives

You want… Prefers
To keep loading more as someone scrolls, instead of discrete, addressable pages eun-infinite-list

Guidance

  • Set page back 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 disabled while the requested page's data is loading, so a second click can't race the first (see the Examples tab)
  • Keep totalPages in sync with your actual data, since eun-pagination has no way to know it on its own
  • Leave siblingCount/boundaryCount at their defaults unless you have a specific reason to show more or fewer page numbers, since the defaults already match common pagination conventions
  • Recompute totalPages and reset page to 1 when applying a eunpagesizechange, since the old page position no longer means anything once the page size changes (see the Examples tab)
  • Expecting page to 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

NameTypeDefaultDescription
total-pagesnumber1The total number of pages
sibling-countnumber1How many page numbers to show on each side of the current page before collapsing the rest into an ellipsis gap
boundary-countnumber1How many page numbers to always show at the very start and end
hide-prev-nextbooleanfalseHides the previous and next controls
hide-first-lastbooleanfalseHides the first and last controls
show-labelsbooleanfalseShows a visible text label next to the first, previous, next, and last icons, instead of icon-only controls
page-sizenumber—The current page size, in items per page. Only meaningful once pageSizeOptions is also set
page-size-optionsArray<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-labelstring—The label for the page-size selector. Defaults to the localized string for the current locale (English: "Items per page")
page-size-placementEunomiaPaginationPageSizePlacement'end'Which edge of the row the page-size selector sits against
page-size-hide-below'mobile' | 'tablet' | 'laptop' | 'desktop' | number—Hides the page-size selector once the pagination itself is narrower than this width, a breakpoint name or a number of pixels. Wins over --pagination-page-size-hide-below
page-size-hiddenboolean—Set by the component while the page-size selector is hidden through page-size-hide-below or --pagination-page-size-hide-below. Read only
previous-labelstring—The accessible label of the previous control. Defaults to the localized string for the current locale (English: "Previous page")
next-labelstring—The accessible label of the next control. Defaults to the localized string for the current locale (English: "Next page")
first-labelstring—The accessible label of the first control. Defaults to the localized string for the current locale (English: "First page")
last-labelstring—The accessible label of the last control. Defaults to the localized string for the current locale (English: "Last page")
page-labelstring—The accessible label prefix applied to every page number control, such as Page 3. Defaults to the localized string for the current locale (English: "Page")
aria-labelstring—The accessible label for the navigation landmark. Defaults to the localized string for the current locale (English: "Pagination")
pagenumber1The current page, starting at 1
disabledbooleanfalseDisables every control, such as while the current page's data is loading
alignEunomiaPaginationAlign'start'Where the page controls sit within the row. Center centers them on the whole row, the page-size selector staying beside them

Import the exact TypeScript type behind any property above, see Types.

Properties

JS-only — no matching HTML attribute, set these from a script or a template binding.

NameTypeDefaultDescription
resolvedLocale—This instance's own `locale` when set, the shared default locale otherwise. Used for `Intl` formatting, so dates and numbers follow the same locale as dictionary.
dictionary—This instance's locale-resolved string dictionary — see the class doc for the resolution order.

Events

NameTypeDescription
eunpagechangePageChangeEventFired with the requested page whenever a control is activated. Page is never updated internally
eunpagesizechangePageSizeChangeEventFired 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 Events.

CSS custom properties

NameDescription
--pagination-gapSets the gap between controls
--pagination-item-sizeSets the width and height of every control
--pagination-border-radiusSets the corner radius of every control
--pagination-item-colorSets the text and icon color of non-current controls
--pagination-item-color-hoverSets the text and icon color of non-current controls on hover
--pagination-item-background-hoverSets the background color of page-number controls on hover
--pagination-control-backgroundSets the background color of the first, previous, next, and last controls, distinguishing them from page numbers
--pagination-control-background-hoverSets the background color of the first, previous, next, and last controls on hover
--pagination-current-backgroundSets the background color of the current page
--pagination-current-colorSets the text color of the current page
--pagination-disabled-colorSets the text and icon color of disabled controls
--pagination-ellipsis-colorSets the color of the ellipsis
--pagination-focus-outline-colorSets the color of a control's focus outline
--pagination-font-sizeSets the font size of every control
--pagination-transition-durationSets the duration of the hover color and background transition
--pagination-page-size-widthSets the width of the page-size selector
--pagination-page-size-hide-belowSets the width below which the page-size selector is hidden, a breakpoint name or a number of pixels. The page-size-hide-below attribute wins over it. Read again whenever the pagination resizes

Page range

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>

Controls

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>

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
});

Placement

page-size-placement sets which edge of the row the selector sits against, end by default or start. Its width is 100px by default, and --pagination-page-size-width changes it.

<eun-pagination
  page="4"
  total-pages="12"
  page-size="10"
  page-size-options="[10, 25, 50]"
  page-size-placement="start"
  aria-label="Pagination"
></eun-pagination>

Hiding on narrow widths

page-size-hide-below hides the selector once the pagination is narrower than a breakpoint (mobile, tablet, laptop or desktop) or a number of pixels. The width compared is the pagination's own, not the screen's, so it reacts the same way inside a narrow card or side panel.

<eun-pagination
  page="4"
  total-pages="12"
  page-size="10"
  page-size-options="[10, 25, 50]"
  page-size-hide-below="mobile"
  aria-label="Pagination"
></eun-pagination>

The same threshold can come from CSS with --pagination-page-size-hide-below, to set it once for a whole page or change it from your own media queries. The attribute wins over it when both are set.

eun-pagination {
  --pagination-page-size-hide-below: 520px;
}

@media (width < 1024px) {
  eun-pagination {
    --pagination-page-size-hide-below: tablet;
  }
}

Layout

Alignment

align places the page controls at the start (default), center or end of the row. Centered, they sit in the middle of the whole row, and the page-size selector stays beside them without shifting them off center.

<eun-pagination
  page="4"
  total-pages="12"
  page-size="10"
  page-size-options="[10, 25, 50]"
  align="center"
  aria-label="Pagination"
></eun-pagination>

Narrow spaces

The pagination always stays on a single line. When its row runs out of width, it first drops the visible labels, then the First and Last controls, then the pages around the current one, and finally keeps only the current page between Previous and Next. Everything comes back as soon as there's room again.

<div style="max-width: 280px">
  <eun-pagination
    page="10"
    total-pages="30"
    show-labels
    aria-label="Pagination"
  ></eun-pagination>
</div>

Integration

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;
});

Server-side rendering

The pagination draws its controls from its attributes, so a server-rendered pagination stays empty until the element upgrades, then pushes the content below it down. Link pagination-ssr.css in the page's <head> to reserve the height of its row from the first paint:

<link
  rel="stylesheet"
  href="node_modules/@eunomia/elements/dist/ssr/pagination-ssr.css"
/>

A bundler that resolves package exports can reach it as @eunomia/elements/pagination-ssr.css.

It follows --pagination-item-size. The controls themselves appear with the upgrade, and so does the page-size selector, whose height isn't reserved.

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;
}

The custom properties that change colors, borders, shadows and corners, at a glance. The API tab lists every property of the component.

Variable Sets
--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

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 as eun-breadcrumb. Its default label follows the active locale, and aria-label overrides it for a specific context.
  • role="list" is set explicitly despite <ul> having it implicitly: Safari drops the implicit role once list-style: none is 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, mirroring eun-breadcrumb's current step, since activating the page someone's already on is a confusing, redundant control.
  • First/Previous/Next/Last and every page-number control carry an explicit aria-label (first-label/previous-label/next-label/last-label/page-label), since a lone chevron icon or a bare digit has no accessible name on its own otherwise.
  • Unreachable controls (Previous/First on page 1, Next/Last on the last page, or every control while disabled is set) use the native disabled attribute, not aria-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 showLabels is set, First/Previous/Next/Last drop their aria-label entirely 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-icon stays aria-hidden either way, so it never contributes to that name.
  • The page-size selector (pageSizeOptions, see the Examples tab) is a eun-select field, given its own accessible name through pageSizeLabel, 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-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

WAI-ARIA Authoring Practices: Patterns
WAI Web Accessibility Tutorials: Labeling Controls