Vanilla UI Kit Pagination v1.0.0 · toast →

Pagination, one file.

Zero-dependency page controls that follow your page's theme on their own. Real buttons, real ARIA — every card below is live.

Basic

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

Siblings & boundaries

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

Compact

Just prev · 3 / 12 · next — for toolbars and tight corners.

new Pagination('#compact', {
  pages: 12, page: 3, compact: true
})

Total + jump

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

Driven by data

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

    API reference

    The complete Pagination API — constructor, every option, instance methods, statics, events, declarative attributes, theming and accessibility.

    Constructor & usage

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

    Options

    Every key of Pagination.defaults, with its default value.

    NameTypeDefaultDescription
    totalnumber | nullnull Item count — pairs with perPage to derive the page count (ceil(total / perPage), minimum 1).
    perPagenumber10 Items per page (minimum 1).
    pagesnumber | nullnull OR pass the page count directly — takes precedence over total/perPage.
    pagenumber1 Initial page, 1-based, clamped to [1, pageCount].
    siblingsnumber1 Pages shown on each side of the current one.
    boundariesnumber1 Pages pinned at each end of the strip.
    compactbooleanfalse Swap the page strip for a live 3 / 12 readout between the prev/next buttons.
    showTotalboolean | functionfalse true = built-in "1–10 of 97" readout, or a formatter (total, [from, to]) => string. Only rendered in total/perPage mode.
    showJumpbooleanfalse Labelled "Go to" number input; Enter jumps to the clamped page and never submits a surrounding form.
    themestring'auto' 'auto' | 'light' | 'dark'. Auto re-resolves live against the page theme.
    stylesbooleantrue false = headless: no CSS is injected — style the .vpn-* markup yourself.
    onChangefunction | nullnull fn(page, pagination) — fired on every real page change (never for the initial clamp).
    labelsobjectsee below Every string is replaceable (i18n); merged key-by-key over the defaults.

    The labels object

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

    Methods

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

    Statics & helpers

    StaticDescription
    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'.

    Events & callbacks

    Two channels report every real page move (a setPage to the same page, an initial clamp, or {silent: true} fire neither):

    document.getElementById('pager').addEventListener('pagination:change', (e) => {
      console.log(e.detail.page)        // the new page
      console.log(e.detail.pagination)  // the instance
    })

    Declarative init

    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>
    AttributeMaps toNotes
    data-vpn—Marks the container.
    data-total / data-per-page / data-pages / data-pagetotal/perPage/pages/pageNumbers.
    data-siblings / data-boundariessiblings/boundariesNumbers.
    data-compact / data-show-total / data-show-jump / data-stylescompact/showTotal/showJump/stylesBooleans; present = true, "false" and "0" = false.
    data-themethemeauto | light | dark.

    Theming

    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

    Accessibility

    Headless mode

    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.

    Unstyled (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) { /* … */ }
    })

    Bring your own design (Tailwind)

    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>