PaginationMOL
Page navigation with previous/next links and page numbers.
On this page (12)
§Default
Data-driven: declare data-active-page / data-min-page / data-max-page / data-page-display-count and the component renders the page window, ellipses and prev/next disabled states itself.
§Icons only
Compact pagination with just chevron arrows - useful alongside a 'rows per page' selector.
§Simple
Page numbers only - no previous/next text labels.
§Disabled at boundaries
Previous is disabled on the first page; next is disabled on the last page.
§Outline
data-variant='outline' - every cell a bordered button; the active page fills with the accent surface so it still stands out.
§Joined
data-variant='joined' - one segmented bar: neighbours share a border, only the outer corners round (logical radii - mirrors in RTL), the ellipsis joins the chain.
§Previous / next (split)
data-layout='split' - two equal columns across the container, the pager under an article; combines with any variant. aria-disabled='true' alone mutes a boundary link.
§Sizes
Set data-size on the .pagination nav; every cell scales - the default equals md.
§Density
Set data-density on the component root to scale its internal whitespace. A whitespace policy, not a zoom: only gaps and padding scale (ratio 0.75 / 1 / 1.25), typography and fixed dimensions stay identical. comfortable matches the unsized default.
§States
The runtime declares the single default state; the editable contract below lives on the nav/list as data-* attributes, driven per instance through the bound api or the State tab:
Machine contract - verified against pagination.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
activePage | number | — | 3 | Current page - rendered aria-current="page"; changed by links, next()/prev() actions or the panel. |
minPage | number | — | 1 | First selectable page (panel edits re-render the links). |
maxPage | number | — | 20 | Last selectable page (panel edits re-render the links). |
pageDisplayCount | number | — | 3 | How many page links the window shows around the active page (rest collapse to ellipsis). |
size | enum | xs, sm, md, lg, xl | "md" | Link scale (display sizes). |
§JavaScript view file
The optional progressive-enhancement layer: a data-driven nav (data-active-page + data-min-page / data-max-page / data-page-display-count) gets its link window keyed-morphed around the active page, prev/next stay honest, and clicks plus the pagination-next/pagination-prev action events step the page. Authored markup without those attributes renders exactly as written.
// -- Pagination -----------------------------------------------// Window renderer for the static pagination markup. The nav carries its// contract as data attributes (data-active-page / -min-page / -max-page /// -page-display-count); this script computes the link window around the// active page, keeps prev/next honest, and turns link clicks + the// next()/back() action events into page changes (AGENTS.md "State API").//// Attribute-driven on purpose: the sandbox bridge mutates DOM natively, so// writing data-active-page (panel editor) must visibly re-render - the// MutationObserver below is that bridge between "attribute changed" and// "window recomputed". The authored markup stays fully functional without// this file (progressive enhancement); the script only adds the window// math and the interaction.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.// defussQuery: the window is (re)rendered through keyed morph (plans/// defuss-query-morph-integration.md §3: link nodes keep identity across a// page change - focus survives).import { defussGlobals, defussQuery } from '../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();const paginationStates = ['default'];/** Read one numeric data attribute (camelCase key) with a fallback. */const numAttr = (el: HTMLElement, key: string, fallback: number): number => { const v = parseInt(el.dataset[key] ?? '', 10); return Number.isFinite(v) ? v : fallback;};/** * Render the link window between the authored prev/next items: `count` * consecutive page links containing the active page (clamped to the range, * ellipsis where pages are skipped). Idempotent: running it twice on the * same attribute state produces the same DOM (morph keys by data-page). */function renderWindow(nav: HTMLElement): void { const list = nav.querySelector('.pagination-list'); const prev = nav.querySelector('.pagination-prev'); const next = nav.querySelector('.pagination-next'); const prevLi = prev?.closest('li') ?? null; const nextLi = next?.closest('li') ?? null; if (!list || !prev || !next || !prevLi || !nextLi) return; const min = Math.max(1, numAttr(nav, 'minPage', 1)); const max = Math.max(min, numAttr(nav, 'maxPage', 1)); const active = Math.min(max, Math.max(min, numAttr(nav, 'activePage', min))); // native disabled affordance the CSS already styles (aria-disabled leg) dfDollar(prev).attr('aria-disabled', active <= min ? 'true' : null); dfDollar(next).attr('aria-disabled', active >= max ? 'true' : null); if (!nav.hasAttribute('data-active-page')) return; // authored window: untouched const count = Math.max(1, numAttr(nav, 'pageDisplayCount', 5)); // guard the write: a same-value setAttribute STILL fires a MutationObserver // record - unguarded, the attribute MO below would re-render forever if (nav.dataset.activePage !== String(active)) nav.dataset.activePage = String(active); // window start: center `active` in `count` slots, slide to stay in range const start = Math.max(min, Math.min(active - Math.floor((count - 1) / 2), max - count + 1)); const end = Math.min(max, start + count - 1); // 1. the current window (everything between the authored prev/next items): // page links that stay visible MOVE (node identity → focus survives), // everything else leaves the document const survivors = new Map<number, HTMLElement>(); let n = prevLi.nextSibling as ChildNode | null; while (n && n !== nextLi) { const node = n as HTMLElement; n = n.nextSibling as ChildNode | null; const page = node.querySelector?.('.pagination-link[data-page]')?.getAttribute('data-page'); const p = page ? parseInt(page, 10) : NaN; if (Number.isFinite(p) && p >= start && p <= end) { node.remove(); // detach, then re-insert in order below survivors.set(p, node); } else node.remove(); } // 2. build the ordered window (fresh nodes for pages that appear) and insert // everything before the authored next item - .before() is the sanctioned // move op (AGENTS.md DOM boundary), inserted in order so it lands sorted for (const node of windowNodes(start, end, min, max, active, survivors)) dfDollar(nextLi).before(node);}/** * Ordered <li> nodes for the window [start, end]: leading/trailing ellipsis * when pages are skipped, reused nodes from `survivors` where a page stays * visible, fresh (data-page-keyed) nodes otherwise. Active page is rebuilt * every time (its class/aria are the state - a reused node could be stale). */function windowNodes( start: number, end: number, min: number, max: number, active: number, survivors: Map<number, HTMLElement>,): HTMLElement[] { const out: HTMLElement[] = []; const ellipsis = () => { const li = document.createElement('li'); const s = document.createElement('span'); s.className = 'pagination-ellipsis'; s.setAttribute('aria-hidden', 'true'); s.textContent = '…'; li.append(s); return li; }; const pageLink = (p: number): HTMLElement => { const li = document.createElement('li'); const a = document.createElement('a'); a.className = 'pagination-link' + (p === active ? ' pagination-active' : ''); a.href = '#'; a.dataset.page = String(p); if (p === active) a.setAttribute('aria-current', 'page'); a.textContent = String(p); li.append(a); return li; }; if (start > min) out.push(ellipsis()); for (let p = start; p <= end; p++) { if (p === active || !survivors.has(p)) out.push(pageLink(p)); else { // reused link: drop stale active markers (it WAS the active page before // this render - its class/aria are now wrong) const node = survivors.get(p)!; const a = node.querySelector('a'); a?.classList.remove('pagination-active'); a?.removeAttribute('aria-current'); out.push(node); } } if (end < max) out.push(ellipsis()); return out;}/** * Move the active page by a delta (clamped) and re-render - the single path * for link clicks, prev/next clicks and the next()/back() action events. */function setPage(nav: HTMLElement, page: number): void { const min = Math.max(1, numAttr(nav, 'minPage', 1)); const max = Math.max(min, numAttr(nav, 'maxPage', 1)); const next = Math.min(max, Math.max(min, page)); if (next === numAttr(nav, 'activePage', min)) return; nav.dataset.activePage = String(next); // the attribute MO re-renders nav.dispatchEvent(new CustomEvent('pagination-change', { bubbles: true, detail: { page: next } }));}/** * UI side of setState: 'default' applies an optional { page|activePage, * minPage, maxPage, pageDisplayCount } config onto the attributes (the MO * re-renders). Without config it just re-renders the current contract. */function triggerStateChange(nav: HTMLElement, stateName: string, config: Record<string, unknown> = {}): void { if (stateName !== 'default') return; const a = config.activePage ?? config.page; if (a !== undefined) nav.dataset.activePage = String(a); if (config.minPage !== undefined) nav.dataset.minPage = String(config.minPage); if (config.maxPage !== undefined) nav.dataset.maxPage = String(config.maxPage); if (config.pageDisplayCount !== undefined) nav.dataset.pageDisplayCount = String(config.pageDisplayCount); renderWindow(nav);}/** Registry-level API; pass the nav element explicitly. Unknown names throw. */export const paginationApi = { setState(nav: HTMLElement, stateName: string, config: Record<string, unknown> = {}) { if (!paginationStates.includes(stateName)) { throw new Error(`pagination: unknown state "${stateName}" (supported: ${paginationStates.join(', ')})`); } triggerStateChange(nav, stateName, config); // state lives on the ELEMENT, not module scope (AGENTS.md "State API") nav.dataset.stateName = stateName; nav._stateConfig = config; }, getState(nav: HTMLElement) { // reflect reality: clicks and actions move the page without setState() return { name: nav.dataset.stateName || 'default', config: { ...nav._stateConfig, activePage: numAttr(nav, 'activePage', 1), minPage: numAttr(nav, 'minPage', 1), maxPage: numAttr(nav, 'maxPage', 1), pageDisplayCount: numAttr(nav, 'pageDisplayCount', 5), }, }; },};df$.paginationApi = paginationApi;df$.paginationStates = paginationStates;function init(): void { document.querySelectorAll<HTMLElement>('.pagination:not([data-init])').forEach((nav) => { nav.dataset.init = ''; // bind-scope the api per instance: `$('#pager').api.setState('default', { page: 4 })` nav.api = { setState: (stateName: string, config?: Record<string, unknown>) => paginationApi.setState(nav, stateName, config), getState: () => paginationApi.getState(nav), }; // opt-in: only a data-driven nav (one that declares its contract with // data-active-page) gets its window rendered - plain authored markup is // left exactly as written (progressive enhancement) if (nav.hasAttribute('data-active-page')) renderWindow(nav); // attribute-driven re-render: panel edits (bridge writes the data attrs) // and setState both land here - the attributes ARE the state new MutationObserver(() => { if (nav.hasAttribute('data-active-page')) renderWindow(nav); }).observe(nav, { attributes: true, attributeFilter: ['data-active-page', 'data-min-page', 'data-max-page', 'data-page-display-count', 'data-size'], }); // one delegated click path: page links jump, prev/next step (href="#" // would jump to the top - the component owns the interaction) nav.addEventListener('click', (e) => { const link = (e.target as HTMLElement).closest<HTMLElement>('.pagination-link[data-page], .pagination-prev, .pagination-next'); if (!link) return; e.preventDefault(); if (link.classList.contains('pagination-link')) { setPage(nav, parseInt(link.dataset.page ?? '1', 10)); } else if (link.classList.contains('pagination-prev')) { setPage(nav, numAttr(nav, 'activePage', 1) - 1); } else { setPage(nav, numAttr(nav, 'activePage', 1) + 1); } }); // action vocabulary (CodeExample Actions tab dispatches these on the nav) nav.addEventListener('pagination-next', () => setPage(nav, numAttr(nav, 'activePage', 1) + 1)); nav.addEventListener('pagination-prev', () => setPage(nav, numAttr(nav, 'activePage', 1) - 1)); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });§CSS view file
Styles for the pagination component. Uses design tokens for colors, spacing, and radius.
@layer components { .pagination { display: flex; justify-content: center; } .pagination-list { display: flex; align-items: center; gap: 0.25rem; list-style: none; margin: 0; padding: 0; } .pagination-link, .pagination-prev, .pagination-next { display: inline-flex; align-items: center; justify-content: center; min-width: 2.25rem; height: 2.25rem; padding-inline: 0.5rem; border: 1px solid transparent; border-radius: var(--radius-md); font-size: 0.875rem; color: var(--foreground); text-decoration: none; cursor: pointer; outline: none; transition: background-color 150ms ease, border-color 150ms ease; gap: 0.25rem; &:hover { background-color: var(--accent); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } & svg { width: 1rem; height: 1rem; flex-shrink: 0; } /* Disabled state for prev/next at boundaries */ &[aria-disabled="true"], &.disabled { pointer-events: none; opacity: 0.5; } } .pagination-prev, .pagination-next { padding-inline: 0.75rem; gap: 0.375rem; } .pagination-active { border-color: var(--border); background-color: var(--background); font-weight: 500; } .pagination-ellipsis { display: inline-flex; align-items: center; justify-content: center; width: 2.25rem; height: 2.25rem; color: var(--muted-foreground); & span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; } } /* -- Sizes ---------------------------------------------------- Set data-size on the .pagination nav; every cell (page links, prev/next, ellipsis) scales. :where() keeps each rule at one class of specificity so the .pagination-prev padding-inline above still wins per-item. md == the unsized default. */ .pagination:where([data-size="xs"]) { & :is(.pagination-link, .pagination-prev, .pagination-next) { min-width: 1.75rem; height: 1.75rem; font-size: 0.75rem; } & .pagination-ellipsis { width: 1.75rem; height: 1.75rem; } } .pagination:where([data-size="sm"]) { & :is(.pagination-link, .pagination-prev, .pagination-next) { min-width: 2rem; height: 2rem; font-size: 0.8125rem; } & .pagination-ellipsis { width: 2rem; height: 2rem; } } .pagination:where([data-size="md"]) { & :is(.pagination-link, .pagination-prev, .pagination-next) { min-width: 2.25rem; height: 2.25rem; font-size: 0.875rem; } & .pagination-ellipsis { width: 2.25rem; height: 2.25rem; } } .pagination:where([data-size="lg"]) { & :is(.pagination-link, .pagination-prev, .pagination-next) { min-width: 2.5rem; height: 2.5rem; font-size: 1rem; } & .pagination-ellipsis { width: 2.5rem; height: 2.5rem; } } .pagination:where([data-size="xl"]) { & :is(.pagination-link, .pagination-prev, .pagination-next) { min-width: 2.75rem; height: 2.75rem; font-size: 1.125rem; } & .pagination-ellipsis { width: 2.75rem; height: 2.75rem; } } /* -- Variants (data-variant on the .pagination nav) ----------- outline: every cell a bordered button; joined: one segmented bar - shared borders collapse, only the outer corners round (logical radii, so RTL mirrors for free). The active page gets a filled surface so it still stands out among bordered cells. */ .pagination:is([data-variant="outline"], [data-variant="joined"]) { & :is(.pagination-link, .pagination-prev, .pagination-next) { border-color: var(--border); background-color: var(--background); box-shadow: var(--shadow-xs); &:hover { background-color: var(--accent); color: var(--accent-foreground); } } & .pagination-active { background-color: var(--accent); color: var(--accent-foreground); } } .pagination[data-variant="joined"] { & .pagination-list { gap: 0; } & :is(.pagination-link, .pagination-prev, .pagination-next, .pagination-ellipsis) { border-radius: 0; } /* the ellipsis joins the chain instead of floating between two groups */ & .pagination-ellipsis { border: 1px solid var(--border); background-color: var(--background); } & .pagination-list > li:first-child > * { border-start-start-radius: var(--radius-md); border-end-start-radius: var(--radius-md); } & .pagination-list > li:last-child > * { border-start-end-radius: var(--radius-md); border-end-end-radius: var(--radius-md); } & .pagination-list > li + li > * { margin-inline-start: -1px; } /* the hovered / focused / active cell's own border stays on top */ & :is(.pagination-link, .pagination-prev, .pagination-next):is(:hover, :focus-visible), & .pagination-active { position: relative; z-index: 1; } } /* -- Layout: split - previous / next as two equal columns across the full width (daisyUI's two-column pager) --------------------------- */ .pagination[data-layout="split"] { width: 100%; & .pagination-list { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; width: 100%; } & .pagination-list > li { display: flex; } & :is(.pagination-link, .pagination-prev, .pagination-next) { flex: 1; } } .pagination[data-layout="split"][data-variant="joined"] .pagination-list { gap: 0; } /* -- Accessibility ------------------------------------------ */ @media (prefers-reduced-motion: reduce) { .pagination-link, .pagination-prev, .pagination-next { transition: none; } } @media (prefers-contrast: more) { .pagination-link, .pagination-prev, .pagination-next { border-color: var(--border); &:hover { border-color: var(--foreground); } } .pagination-active { border-width: 2px; } } @media (forced-colors: active) { .pagination-active { border-color: Highlight; } .pagination-link, .pagination-prev, .pagination-next { &:hover { forced-color-adjust: none; background: Highlight; color: HighlightText; } &[aria-disabled="true"], &.disabled { color: GrayText; } } .pagination[data-variant="joined"] :is(.pagination-link, .pagination-prev, .pagination-next, .pagination-ellipsis) { border-color: ButtonText; } } /* -- Density ---------------------------------------------------- data-density on the .pagination nav scales only the inter-cell gap — the size ladder owns cell dimensions, so size and density stay independent axes. Ratio mirrors sizing.css (0.75 / 1 / 1.25). */ .pagination:where([data-density="compact"]) { & .pagination-list { gap: 0.125rem; } } .pagination:where([data-density="comfortable"]) { & .pagination-list { gap: 0.25rem; } } .pagination:where([data-density="spacious"]) { & .pagination-list { gap: 0.5rem; } }}Comments, ideas or improvements? Edit this page's source on GitHub