Theme
On this page (12)
Component Skill — components/pagination/component-skill.md

Native basis

<nav> + links for page navigation.

Web Platform APIs

<nav>aria-current="page":focus-visibleprefers-reduced-motionprefers-contrastforced-colors

Classes

.pagination.pagination-list.pagination-link.pagination-prev.pagination-next.pagination-active.pagination-ellipsis

Data attributes

data-variant (outline, joined), data-layout="split", data-size (xs … xl), data-density; data-driven: data-active-page, data-min-page, data-max-page, data-page-display-count.

§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:

StateTypeValuesDefaultDescription
activePagenumber—3Current page - rendered aria-current="page"; changed by links, next()/prev() actions or the panel.
minPagenumber—1First selectable page (panel edits re-render the links).
maxPagenumber—20Last selectable page (panel edits re-render the links).
pageDisplayCountnumber—3How many page links the window shows around the active page (rest collapse to ellipsis).
sizeenumxs, 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