Skip to content
Domphy

Pagination

Use pagination on a div. Pass total (required, number of pages) and an optional value (a plain number or a State<number>) for the current page. The patch internally creates a reactive state if none is provided, renders prev/next arrow buttons and numbered page buttons with smart ellipsis windowing for large page counts. The current page is marked with aria-current="page" and stays focusable (only prev/next are disabled at the bounds) so keyboard focus is not dropped when the page changes. Use color to set the base button tone (default "neutral") and accentColor for the active page highlight (default "primary").

Props

PropTypeDefaultDescription
valueValueOrState<number>1Current page (1-based). Accepts a plain number or a reactive State.
totalnumber—Total number of pages. Required.
colorThemeColor"neutral"Base color tone for the page buttons.
accentColorThemeColor"primary"Accent color tone for the active page button.
Customization

Must see the source of patch at the bottom of each patch page to understand the structure then code it still code as html native element.

There are four levels of customization, in increasing order of effort:

  1. Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
  2. Context attributes. Use dataTone, dataSize, and dataDensity on a container to shift tone, size, or density for an entire subtree without touching individual elements.
  3. Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
  4. Create a variant. Clone a similar patch and edit it. Use this only when you need a reusable custom version.
Formulas

Unit - U = fontSize / 4 - convert final values with themeSpacing(n).

Size - n = intrinsic text lines, w = wrapping level, d = density factor:

height        = (n * 6 + 2 * d * w) * U
paddingBlock  = d * w * U
paddingInline = ceil(3 / w) * d * w * U
radius        = d * w * U

Base density d = 1.5:

Uw=0w=1w=2w=3
height (n = 1)691215
paddingBlock01.534.5
paddingInline34.564.5
radius01.534.5

Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.

RoleShiftn=0
Backgroundparent +/- n0
Textbg + K6
Borderbg + K/23
Hoverbg + 2K/34
Selected / Focusabove +/- K/32-4

State shift range: K/3 <= delta <= 2K/3.

import {
  behavior,
  type DomphyElement,
  type ElementNode,
  isState,
  type PartialElement,
  rawHtml,
  type State,
  toState,
  type ValueOrState,
} from "@domphy/core";
import {
  type ThemeColor,
  themeColor,
  themeDensity,
  themeSize,
  themeSpacing,
  themeWeight,
} from "@domphy/theme";
import { chevron } from "../utils/chevron.js";
import { focusRing } from "../utils/focusRing.js";

function getPages(current: number, total: number): (number | "...")[] {
  if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
  const pages: (number | "...")[] = [1];
  if (current > 3) pages.push("...");
  const start = Math.max(2, current - 1);
  const end = Math.min(total - 1, current + 1);
  for (let i = start; i <= end; i++) pages.push(i);
  if (current < total - 2) pages.push("...");
  pages.push(total);
  return pages;
}

function persistValue<T>(
  node: ElementNode,
  key: string,
  incoming: ValueOrState<T> | undefined,
  fallback: T,
): State<T> {
  if (isState(incoming)) {
    node.setMetadata(key, incoming);
    return incoming as State<T>;
  }
  let stored = node.getMetadata(key) as State<T> | undefined;
  if (!stored) {
    stored = toState((incoming as T | undefined) ?? fallback);
    node.setMetadata(key, stored);
  }
  return stored;
}

function persistTotal(node: ElementNode, total: number): State<number> {
  let stored = node.getMetadata("paginationTotal") as State<number> | undefined;
  if (!stored) {
    stored = toState(total);
    node.setMetadata("paginationTotal", stored);
  }
  return stored;
}

/**
 * Themed pagination control. Renders previous/next buttons plus truncated page
 * numbers (with ellipses), tracks the current page in a `State`, and updates it
 * on click. Apply to a `<div>` element.
 *
 * @hostTag div
 * @param props - Configuration.
 * @param props.total - Required. Total number of pages.
 * @param props.value - Current page, accepts a value or `State`. Defaults to `1`.
 * @param props.color - Base color tone for the page buttons. Defaults to `"neutral"`.
 * @param props.accentColor - Accent color tone for the active page. Defaults to `"primary"`.
 * @example { div: null, $: [pagination({ total: 10, value: 1 })] }
 */
function pagination(props: {
  value?: ValueOrState<number>;
  total: number;
  color?: ThemeColor;
  accentColor?: ThemeColor;
}): PartialElement {
  const { total, color = "neutral", accentColor = "primary" } = props;

  const buttonBase = {
    display: "inline-flex",
    alignItems: "center",
    justifyContent: "center",
    minWidth: (listener: any) => themeSpacing(6 + themeDensity(listener) * 2),
    // Square at the canonical height, but padding-driven so a taller inherited
    // line-height grows the box instead of overflowing a fixed height.
    minHeight: (listener: any) => themeSpacing(6 + themeDensity(listener) * 2),
    lineHeight: "inherit",
    paddingBlock: (listener: any) => themeSpacing(themeDensity(listener) * 1),
    paddingInline: (listener: any) => themeSpacing(themeDensity(listener) * 2),
    borderRadius: (listener: any) => themeSpacing(themeDensity(listener) * 1.5),
    border: "none",
    cursor: "pointer",
    fontSize: (listener: any) => themeSize(listener, "inherit"),
    backgroundColor: "transparent",
    // Page numbers: shift-13 for readable inactive digits (catalog contrast).
    color: (listener: any) => themeColor(listener, "shift-13", color),
    transition:
      "background-color 140ms ease, box-shadow 140ms ease, color 140ms ease",
    "&:hover:not([disabled])": {
      color: (listener: any) => themeColor(listener, "shift-13", color),
      backgroundColor: (listener: any) => themeColor(listener, "hover", color),
    },
    "&:focus-visible": {
      boxShadow: (listener: any) => focusRing(listener, accentColor),
    },
    "&[disabled]": {
      opacity: 0.4,
      cursor: "not-allowed",
    },
  };

  const activeStyle = {
    ...buttonBase,
    // Deep accent fill + light text (mid-ramp + mid text failed catalog).
    backgroundColor: (listener: any) =>
      themeColor(listener, "shift-13", accentColor),
    color: (listener: any) => themeColor(listener, "shift-0", "neutral"),
    fontWeight: themeWeight("bold"),
    cursor: "default",
    "&:hover:not([disabled])": {
      backgroundColor: (listener: any) =>
        themeColor(listener, "shift-13", accentColor),
    },
  };

  return {
    role: "navigation",
    ariaLabel: "Pagination",
    _onInsert: (node) => {
      if (node.tagName !== "div")
        console.warn('"pagination" patch must use div tag');
    },
    _onInit: (node) => {
      const state = persistValue(node, "paginationPage", props.value, 1);
      const totalState = persistTotal(node, total);
      const content: DomphyElement<"div"> = {
        div: (listener) => {
          const page = state.get(listener);
          const pageCount = totalState.get(listener);
          const items: DomphyElement[] = [];

          // Prev button
          items.push({
            button: rawHtml(chevron("left")),
            _key: "prev",
            type: "button",
            ariaLabel: "Previous page",
            disabled: page <= 1,
            onClick: () => page > 1 && state.set(page - 1),
            style: buttonBase,
          });

          // Page buttons. Keyed by page number (or a running ellipsis index —
          // there can be up to two "..." spans) so the list survives length
          // changes across pages without the unkeyed-reuse footgun.
          let ellipsisIndex = 0;
          for (const p of getPages(page, pageCount)) {
            if (p === "...") {
              items.push({
                span: "…",
                _key: `ellipsis-${ellipsisIndex++}`,
                ariaHidden: "true",
                style: {
                  display: "inline-flex",
                  alignItems: "center",
                  // The same cell height as the page buttons it sits between,
                  // or the glyph reads low against their digits.
                  minHeight: (listener: any) =>
                    themeSpacing(6 + themeDensity(listener) * 2),
                  paddingInline: (listener: any) =>
                    themeSpacing(themeDensity(listener) * 2),
                  color: (listener: any) =>
                    themeColor(listener, "shift-7", color),
                },
              });
            } else {
              const isActive = p === page;
              items.push({
                button: String(p),
                _key: `page-${p}`,
                type: "button",
                ariaLabel: `Page ${p}`,
                ariaCurrent: isActive ? "page" : undefined,
                // The current page stays enabled (shadcn PaginationLink /
                // MUI Pagination parity). `disabled` on the just-clicked
                // button pulls it out of the tab order the instant it becomes
                // current, so the browser drops focus to <body> and a keyboard
                // user is stranded outside the control after every page
                // change. `aria-current="page"` already conveys the state.
                onClick: () => state.set(p),
                style: isActive ? activeStyle : buttonBase,
              });
            }
          }

          // Next button
          items.push({
            button: rawHtml(chevron("right")),
            _key: "next",
            type: "button",
            ariaLabel: "Next page",
            disabled: page >= pageCount,
            onClick: () => page < pageCount && state.set(page + 1),
            style: buttonBase,
          });

          return items;
        },
        style: {
          display: "flex",
          alignItems: "center",
          gap: themeSpacing(1),
        },
      };
      node.children.insert(content);
    },
    ...behavior<{ total: number }>(
      "pagination-total",
      (node) => ({
        update(snapshot) {
          persistTotal(node, snapshot.total).set(snapshot.total);
        },
      }),
      { total },
    ),
    style: {
      display: "inline-flex",
    },
  };
}

export { pagination };