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. Use color to set the base button tone (default "neutral") and accentColor for the active page highlight (default "primary").
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | ValueOrState<number> | 1 | Current page. Accepts a plain number or a reactive State. Optional. |
total | number | — | Total number of pages. Required. |
color | ThemeColor | "neutral" | Base color tone for the page buttons. |
accentColor | ThemeColor | "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:
- Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
- Context attributes. Use
dataTone,dataSize, anddataDensityon a container to shift tone, size, or density for an entire subtree without touching individual elements. - Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
- 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 * UBase density d = 1.5:
| U | w=0 | w=1 | w=2 | w=3 |
|---|---|---|---|---|
height (n = 1) | 6 | 9 | 12 | 15 |
| paddingBlock | 0 | 1.5 | 3 | 4.5 |
| paddingInline | 3 | 4.5 | 6 | 4.5 |
| radius | 0 | 1.5 | 3 | 4.5 |
Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.
| Role | Shift | n=0 |
|---|---|---|
| Background | parent +/- n | 0 |
| Text | bg + K | 6 |
| Border | bg + K/2 | 3 |
| Hover | bg + 2K/3 | 4 |
| Selected / Focus | above +/- K/3 | 2-4 |
State shift range: K/3 <= delta <= 2K/3.
import {
type DomphyElement,
type PartialElement,
toState,
type ValueOrState,
} from "@domphy/core";
import {
type ThemeColor,
themeColor,
themeDensity,
themeSize,
themeSpacing,
} from "@domphy/theme";
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;
}
/**
* 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: "", $: [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 state = toState(props.value ?? 1);
const btnBase = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minWidth: (listener: any) => themeSpacing(6 + themeDensity(listener) * 2),
height: (listener: any) => themeSpacing(6 + themeDensity(listener) * 2),
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 = {
...btnBase,
// 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: "bold",
cursor: "default",
"&:hover:not([disabled])": {
backgroundColor: (listener: any) =>
themeColor(listener, "shift-13", accentColor),
},
};
return {
role: "navigation",
ariaLabel: "Pagination",
// Active page weight is design-system chrome for the control.
_doctorDisable: "inline-typography",
_onInsert: (node) => {
if (node.tagName !== "div")
console.warn('"pagination" patch must use div tag');
},
_onInit: (node) => {
const content: DomphyElement<"div"> = {
div: (listener) => {
const page = state.get(listener);
const items: DomphyElement[] = [];
// Prev button
items.push({
button: "‹",
_key: "prev",
type: "button",
ariaLabel: "Previous page",
disabled: page <= 1,
onClick: () => page > 1 && state.set(page - 1),
style: btnBase,
});
// 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, total)) {
if (p === "...") {
items.push({
span: "…",
_key: `ellipsis-${ellipsisIndex++}`,
ariaHidden: "true",
style: {
display: "inline-flex",
alignItems: "center",
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,
disabled: isActive,
onClick: () => state.set(p),
style: isActive ? activeStyle : btnBase,
});
}
}
// Next button
items.push({
button: "›",
_key: "next",
type: "button",
ariaLabel: "Next page",
disabled: page >= total,
onClick: () => page < total && state.set(page + 1),
style: btnBase,
});
return items;
},
style: {
display: "flex",
alignItems: "center",
gap: themeSpacing(1),
},
};
node.children.insert(content);
},
style: {
display: "inline-flex",
},
};
}
export { pagination };