Theme
Design your own
On this page (13)
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).

§API

Generated from pagination.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type PaginationState = 'default' - setState(name, config) takes the config of the state it names.

StateDescription
default
The links for the current page and range.
Config fieldTypeDescription
activePage?numberthe page to make active (clamped to the range)
page?numberthe same as activePage (when activePage is not given)
minPage?numberthe first page
maxPage?numberthe last page
pageDisplayCount?numberhow many page links show at once (default 5)

Every element

MemberDescription
el.api.setState<S extends PaginationState>(name: S, config?: PaginationStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?PaginationStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: PaginationState; config: PaginationStateConfigs[PaginationState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: PaginationState; config: PaginationStateConfigs[PaginationState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: PaginationState; config: PaginationStateConfigs[PaginationState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: PaginationState; config: PaginationStateConfigs[PaginationState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: PaginationState; config: PaginationStateConfigs[PaginationState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.paginationApi.setState<S extends PaginationState>(el: HTMLElement, name: S, config?: PaginationStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?PaginationStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.paginationApi.getState(el: HTMLElement): { name: PaginationState; config: PaginationStateConfigs[PaginationState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: PaginationState; config: PaginationStateConfigs[PaginationState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.paginationApi.render(state: { name: PaginationState; config: PaginationStateConfigs[PaginationState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: PaginationState; config: PaginationStateConfigs[PaginationState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.paginationApi.store(el: HTMLElement): Store<{ name: PaginationState; config: PaginationStateConfigs[PaginationState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: PaginationState; config: PaginationStateConfigs[PaginationState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.paginationApi.commit<S extends PaginationState>(el: HTMLElement, name: S, config?: PaginationStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?PaginationStateConfigs[S]its config
df$.shadcn.paginationStates: PaginationState[]The declared states, 'default' first: default.

Events

EventDescription
pagination-change
Fires when the user changes the page (a link, the arrows) - the new page.

detail: PaginationChangeDetail

FieldTypeDescription
pagenumberthe page now active, clamped to data-min / data-max

Types

TypeDescription
PaginationChangeDetail
What pagination-change carries.
FieldTypeDescription
pagenumberthe page now active, clamped to data-min / data-max

§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, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type
// this file's API docs state - arguments, return values, event details - holds
// against its code: a wrong one is a new type error and fails the build.
/** What pagination-change carries. */
interface PaginationChangeDetail {
  /** the page now active, clamped to data-min / data-max */
  page: number;
}
const paginationStates = ['default'];
/** setState() configs per state (getState() reports the live page and range). */
export interface PaginationStateConfigs {
  /** The links for the current page and range. */
  default: {
    /** the page to make active (clamped to the range) */
    activePage?: number;
    /** the same as activePage (when activePage is not given) */
    page?: number;
    /** the first page */
    minPage?: number;
    /** the last page */
    maxPage?: number;
    /** how many page links show at once (default 5) */
    pageDisplayCount?: number;
  };
}
/** 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 = dfDollar(nav).find('.pagination-list').get(0);
  const prev = dfDollar(nav).find('.pagination-prev').get(0);
  const next = dfDollar(nav).find('.pagination-next').get(0);
  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.nodeType === Node.ELEMENT_NODE ? dfDollar(node).find('.pagination-link[data-page]').attr('data-page') : null;
    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 = dfDollar(node).find('a').get(0);
      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
  // Fires when the user changes the page (a link, the arrows) - the new page.
  nav.dispatchEvent(new CustomEvent<PaginationChangeDetail>('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.
 */
/**
 * The pager attributes a config names - only where they differ from what the
 * markup already says (getState() reports the defaults - page 1, 5 slots -
 * even when none was authored): setState(getState()) changes nothing.
 */
function applyConfig(nav: HTMLElement, config: Record<string, unknown>): void {
  const a = config.activePage ?? config.page;
  if (a !== undefined && numAttr(nav, 'activePage', 1) !== Number(a)) nav.dataset.activePage = String(a);
  if (config.minPage !== undefined && numAttr(nav, 'minPage', 1) !== Number(config.minPage)) nav.dataset.minPage = String(config.minPage);
  if (config.maxPage !== undefined && numAttr(nav, 'maxPage', 1) !== Number(config.maxPage)) nav.dataset.maxPage = String(config.maxPage);
  if (config.pageDisplayCount !== undefined && numAttr(nav, 'pageDisplayCount', 5) !== Number(config.pageDisplayCount)) nav.dataset.pageDisplayCount = String(config.pageDisplayCount);
}
/** The markup of a state, for render(): the same config + window render the
 *  live nav runs on setState, on a detached copy of the authored markup. */
function applyMarkup(nav: HTMLElement, config: Record<string, unknown> = {}): void {
  applyConfig(nav, config);
  if (nav.hasAttribute('data-active-page')) renderWindow(nav);
}
function triggerStateChange(nav: HTMLElement, stateName: string, config: Record<string, unknown> = {}): void {
  if (stateName !== 'default') return;
  applyConfig(nav, config);
  // the same opt-in as init: only a data-driven nav is re-rendered - an
  // authored one stays exactly as written
  if (nav.hasAttribute('data-active-page')) renderWindow(nav);
}
/** Registry-level API; pass the nav element explicitly. Unknown names throw. */
export const paginationApi = componentState({
  component: 'pagination',
  states: paginationStates,
  apply: (nav, state) => triggerStateChange(nav, state.name, state.config),
  read: (nav, state) => {
    // reflect reality: clicks and actions move the page without setState()
    return {
      name: nav.dataset.stateName || 'default',
      config: {
        ...state.config,
        activePage: numAttr(nav, 'activePage', 1),
        minPage: numAttr(nav, 'minPage', 1),
        maxPage: numAttr(nav, 'maxPage', 1),
        pageDisplayCount: numAttr(nav, 'pageDisplayCount', 5),
      },
    };
  },
  markup: (el, state) => applyMarkup(el as HTMLElement, state.config),
});
df$.paginationApi = paginationApi;
df$.paginationStates = paginationStates;
function init(): void {
  (dfDollar('.pagination:not([data-init])').toArray() as HTMLElement[]).forEach((nav) => {
    nav.dataset.init = '';
    // el.store + el.api (AGENTS.md "State through stores")
    bindComponent(nav, paginationApi);
    // 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