Zero-dependency page controls that follow your page's theme on their own. Real buttons, real ARIA — every card below is live.
97 items, 10 per page. Click around — prev/next disable at the ends.
new Pagination('#basic', {
total: 97, // perPage: 10 → 10 pages
onChange: (page) => load(page)
})
Two pages pinned at each end, two on each side of the current page — the strip never changes width while paging.
new Pagination('#windows', {
pages: 42, page: 21,
siblings: 2, boundaries: 2
})
Just prev · 3 / 12 · next — for toolbars and tight corners.
new Pagination('#compact', {
pages: 12, page: 3, compact: true
})
An item-range readout on the side, and a clamped “Go to” field — type 999 and press Enter.
new Pagination('#full', {
total: 137, perPage: 20,
showTotal: true, // or (total, [from, to]) => string
showJump: true
})
The pager and this list share one array — onChange
re-renders the slice, update() re-clamps when the data set shrinks.
const pager = new Pagination('#listpager', {
total: ITEMS.length, perPage: 5, showTotal: true,
onChange: (page) => renderSlice(page)
})
renderSlice(1)
// later, when the data changes:
pager.update({ total: ITEMS.length })
The complete Pagination API — constructor, every option,
instance methods, statics, events, declarative attributes, theming and
accessibility.
new Pagination(target, options?) // → instance
Pagination.create(target, options?) // same as new
target is a selector string or an element — the
<nav> of real buttons is rendered inside it. Give it either
an item count (total + perPage) or the page count
directly (pages, which takes precedence). Constructing on an
element that already holds an instance destroys the old one first; the initial
page is clamped and never fires onChange. A missing
target throws; without a DOM (SSR) the constructor returns an inert no-op
instance.
const pager = new Pagination('#pager', {
total: 97, // 97 items, 10 per page → 10 pages
onChange: (page) => loadPage(page)
})
Every key of Pagination.defaults, with its default value.
| Name | Type | Default | Description |
|---|---|---|---|
total | number | null | null |
Item count — pairs with perPage to derive the page count (ceil(total / perPage), minimum 1). |
perPage | number | 10 |
Items per page (minimum 1). |
pages | number | null | null |
OR pass the page count directly — takes precedence over total/perPage. |
page | number | 1 |
Initial page, 1-based, clamped to [1, pageCount]. |
siblings | number | 1 |
Pages shown on each side of the current one. |
boundaries | number | 1 |
Pages pinned at each end of the strip. |
compact | boolean | false |
Swap the page strip for a live 3 / 12 readout between the prev/next buttons. |
showTotal | boolean | function | false |
true = built-in "1–10 of 97" readout, or a formatter (total, [from, to]) => string. Only rendered in total/perPage mode. |
showJump | boolean | false |
Labelled "Go to" number input; Enter jumps to the clamped page and never submits a surrounding form. |
theme | string | 'auto' |
'auto' | 'light' | 'dark'. Auto re-resolves live against the page theme. |
styles | boolean | true |
false = headless: no CSS is injected — style the .vpn-* markup yourself. |
onChange | function | null | null |
fn(page, pagination) — fired on every real page change (never for the initial clamp). |
labels | object | see below | Every string is replaceable (i18n); merged key-by-key over the defaults. |
labels object| Key | Default | Used for |
|---|---|---|
pagination | 'Pagination' | aria-label on the <nav>. |
prev | 'Prev' | Visible prev text ('' = glyph only). |
next | 'Next' | Visible next text ('' = glyph only). |
prevAria | 'Previous page' | aria-label on the prev button. |
nextAria | 'Next page' | aria-label on the next button. |
page | 'Page' | aria-label prefix on page buttons ("Page 7"). |
jump | 'Go to' | Visible label on the jump field. |
| Method | Returns | Description |
|---|---|---|
getPage() | number | The current page (1-based). |
setPage(p, {silent}?) | instance | Clamped to [1, pageCount]. Fires onChange and the pagination:change event only on an actual move, and never with {silent: true}. |
update({total, perPage, pages}) | instance | Swap the data shape in place; the current page is re-clamped silently (no onChange for the clamp). |
destroy() | instance | Remove the <nav>, its listeners and the theme watcher, and unbind the instance from the element. |
| Static | Description |
|---|---|
Pagination.create(el, opts) |
Same as new Pagination(el, opts). |
Pagination.get(el) |
The instance previously bound to an element (or null). |
Pagination.autoInit(root?) |
Initialize every [data-vpn] under root (default: document); returns the created instances. Runs automatically on DOMContentLoaded. |
Pagination.defaults |
The live defaults object — mutate before constructing. |
Pagination.css |
The full stylesheet as a string (rendered with the current salt) — a starting point for headless styling. |
Pagination.salt |
CSS isolation token, default 'vc1'. Set your own token or false before the first instance. |
Pagination.version |
Version string, '1.0.0'. |
Two channels report every real page move (a setPage to the same
page, an initial clamp, or {silent: true} fire neither):
onChange(page, pagination) — the option callback.pagination:change — a bubbling CustomEvent
dispatched from the target element with
detail: { page, pagination }.document.getElementById('pager').addEventListener('pagination:change', (e) => {
console.log(e.detail.page) // the new page
console.log(e.detail.pagination) // the instance
})
Add data-vpn to a container — instances are built on
DOMContentLoaded (or call Pagination.autoInit(root)
after inserting markup). Listen for pagination:change on the
container to react.
<div data-vpn data-total="97" data-per-page="10" data-show-jump></div>
| Attribute | Maps to | Notes |
|---|---|---|
data-vpn | — | Marks the container. |
data-total / data-per-page / data-pages / data-page | total/perPage/pages/page | Numbers. |
data-siblings / data-boundaries | siblings/boundaries | Numbers. |
data-compact / data-show-total / data-show-jump / data-styles | compact/showTotal/showJump/styles | Booleans; present = true, "false" and "0" = false. |
data-theme | theme | auto | light | dark. |
Auto light/dark with the family's resolution order:
<html data-theme> / data-bs-theme /
.dark class → prefers-color-scheme, re-resolved live.
Pin one instance with theme: 'dark'. All colors are CSS custom
properties on .vpn:
.vpn {
--vpn-accent: #b45309; /* current page, focus rings */
--vpn-text: …; --vpn-muted: …; --vpn-faint: …; --vpn-bg: …;
--vpn-on-accent: …; /* text on the current-page button */
--vpn-radius: 8px; --vpn-font: …;
}
With the VC core loaded, VC.config({ accent: '#b45309' }) themes
the pager and every other family component in one call. The nav renders as
class="vpn vc1"; structural rules ship salted so host design
systems can't override it, while the unsalted --vpn-* variable
definitions keep page overrides working. Change the token with
Pagination.salt = 'acme' (or false) before the first
instance.
Headless: pass styles: false (per instance, or
via Pagination.defaults) and no CSS is injected — you keep the
full behavior (windowing, clamping, ARIA, keyboard) and style this markup
contract yourself (Pagination.css is the reference stylesheet as a
string):
nav.vpn[aria-label="Pagination"][data-theme="dark"]
.vpn-total ← only when showTotal (total mode)
button.vpn-btn.vpn-prev ← disabled on page 1
.vpn-pages ← or .vpn-status[aria-live] when compact
button.vpn-btn[data-page] ← [aria-current="page"] on the current one
.vpn-gap ← the … separators
button.vpn-btn.vpn-next ← disabled on the last page
label.vpn-jump > input ← only when showJump
<nav aria-label="Pagination">; every
page is a real <button>, so keyboard access is native
(Tab + Enter/Space) — no custom key
handling to learn.aria-current="page" and ignores
clicks; prev/next carry full aria-labels (their arrow glyphs
are aria-hidden) and disable themselves at the ends; ellipses
are inert aria-hidden separators.3 / 12 readout is an
aria-live="polite" region, so page flips are announced.<label>-wrapped number input;
Enter jumps to the clamped page and never submits a surrounding
form. Reduced motion is respected.With styles: false Pagination injects no CSS at all — the full
behavior (windowing, clamping, callbacks), native keyboard access, the ARIA
contract and the stable .vpn-* class hooks all remain; you bring
the stylesheet. The demos below each run in their own
<iframe>: the styled examples above have already injected
the component stylesheet into this page, so only a separate, clean document
can show what headless truly looks like.
styles: false)The raw markup contract — a real <nav> of native
<button>s with aria-current="page" on the
current one. Zero CSS was injected, yet it is a fully working pager —
click around.
new Pagination('#pager', {
total: 97, page: 3,
showTotal: true,
styles: false, // ← zero CSS injected
onChange: function (p) { /* … */ }
})
The same class hooks mapped to a deliberately different design with
Tailwind utilities — pill buttons, a filled aria-current
state, a rounded jump field. Nothing loads from the CDN until you
click.
<style type="text/tailwindcss">
.vpn { @apply flex flex-wrap items-center gap-1.5; }
.vpn-total { @apply mr-2 text-xs font-medium text-slate-500; }
.vpn-pages { @apply flex items-center gap-1.5; }
.vpn-btn { @apply h-9 min-w-9 cursor-pointer rounded-full border-0
bg-white px-3 text-sm font-semibold text-slate-600 shadow-sm
ring-1 ring-slate-200 hover:bg-sky-50 hover:text-sky-700
hover:ring-sky-300 disabled:cursor-not-allowed
disabled:opacity-40; }
.vpn-btn[aria-current="page"] { @apply bg-sky-600 text-white ring-sky-600
shadow-md shadow-sky-200; }
.vpn-gap { @apply px-1 text-slate-400; }
.vpn-jump { @apply ml-2 flex items-center gap-1.5 text-xs text-slate-500; }
.vpn-jump input { @apply w-14 rounded-lg border-0 px-2 py-1.5 text-sm
ring-1 ring-slate-200 outline-none focus:ring-2
focus:ring-sky-500; }
</style>