role navigation
Pagination draft v0.1.0
Lets people move between the pages of a long list, with the current page marked.
A pagination splits a long list of results into pages and lets people go to a page by its number, or to the previous and next ones. It shows which page is current. The pages are links: each one has a destination, so the browser handles history and focus. Which page numbers to show (all, a window around the current one) is the application's choice: the component renders the list it is given.
Web implementation
Root .ds-pagination on a <nav>. Properties map to attributes: size → data-sizelabel → aria-label
packages/components/src/components/pagination/pagination.css
/*
* Pagination — vanilla CSS reference implementation.
* Spec: packages/components/specs/components/pagination.spec.json
* Tokens: packages/tokens/dist/css/components/pagination.css (generated from packages/tokens/src/components/pagination.tokens.json)
*
* Rules:
* - Only --pagination-* custom properties may appear here. No raw values, no semantic tokens.
* - The root is a nav landmark; the pages are a list of links; the current page is not a link and
* carries aria-current; a previous or next link without a destination is rendered disabled.
* - Size is a data attribute on the root; link states are native pseudo-classes, forced with
* [data-state] on the root (every item) or on one item.
*/
.ds-pagination {
--_size: var(--pagination-link-size-medium);
--_padding-inline: var(--pagination-link-padding-inline-medium);
--_icon-size: var(--pagination-icon-size-medium);
--_font-family: var(--pagination-link-typography-medium-font-family);
--_font-size: var(--pagination-link-typography-medium-font-size);
--_font-weight: var(--pagination-link-typography-medium-font-weight);
--_line-height: var(--pagination-link-typography-medium-line-height);
--_letter-spacing: var(--pagination-link-typography-medium-letter-spacing);
box-sizing: border-box;
min-inline-size: 0;
}
.ds-pagination[data-size="small"] {
--_size: var(--pagination-link-size-small);
--_padding-inline: var(--pagination-link-padding-inline-small);
--_icon-size: var(--pagination-icon-size-small);
--_font-family: var(--pagination-link-typography-small-font-family);
--_font-size: var(--pagination-link-typography-small-font-size);
--_font-weight: var(--pagination-link-typography-small-font-weight);
--_line-height: var(--pagination-link-typography-small-line-height);
--_letter-spacing: var(--pagination-link-typography-small-letter-spacing);
}
.ds-pagination__list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--pagination-container-gap);
margin: 0;
padding: 0;
list-style: none;
}
.ds-pagination__item {
--_background: var(--pagination-link-background-default);
--_color: var(--pagination-link-color-default);
display: inline-flex;
}
.ds-pagination__link {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--pagination-container-gap);
min-inline-size: var(--_size);
min-height: var(--_size);
padding-block: 0;
padding-inline: var(--_padding-inline);
border-radius: var(--pagination-link-border-radius);
background-color: var(--_background);
color: var(--_color);
font-family: var(--_font-family);
font-size: var(--_font-size);
font-weight: var(--_font-weight);
line-height: var(--_line-height);
letter-spacing: var(--_letter-spacing);
text-decoration: none;
white-space: nowrap;
transition:
background-color var(--pagination-link-transition-duration) var(--pagination-link-transition-easing),
color var(--pagination-link-transition-duration) var(--pagination-link-transition-easing);
}
.ds-pagination__icon {
display: inline-block;
flex: none;
inline-size: var(--_icon-size);
block-size: var(--_icon-size);
overflow: hidden;
font-family: var(--pagination-icon-font-family);
font-size: var(--_icon-size);
font-weight: var(--pagination-icon-font-weight);
font-style: normal;
font-variation-settings:
"FILL" var(--pagination-icon-fill),
"GRAD" var(--pagination-icon-grade),
"opsz" var(--pagination-icon-optical-size);
font-feature-settings: "liga";
line-height: var(--_icon-size);
letter-spacing: normal;
white-space: nowrap;
direction: ltr;
-webkit-font-smoothing: antialiased;
}
/* ---- States -------------------------------------------------------------- */
/* Order: hover < active < current < disabled. A current page and a disabled link do not react to hover. */
:where(.ds-pagination[data-state="hover"], .ds-pagination__item[data-state="hover"]) .ds-pagination__link:not([aria-current], [aria-disabled="true"]),
.ds-pagination__link:hover:not([aria-current], [aria-disabled="true"]) {
--_background: var(--pagination-link-background-hover);
}
:where(.ds-pagination[data-state="active"], .ds-pagination__item[data-state="active"]) .ds-pagination__link:not([aria-current], [aria-disabled="true"]),
.ds-pagination__link:active:not([aria-current], [aria-disabled="true"]) {
--_background: var(--pagination-link-background-active);
}
:where(.ds-pagination[data-state="current"], .ds-pagination__item[data-state="current"]) .ds-pagination__link,
.ds-pagination__link[aria-current] {
--_background: var(--pagination-link-background-current);
--_color: var(--pagination-link-color-current);
}
:where(.ds-pagination[data-state="disabled"], .ds-pagination__item[data-state="disabled"]) .ds-pagination__link,
.ds-pagination__link[aria-disabled="true"] {
--_background: var(--pagination-link-background-disabled);
--_color: var(--pagination-link-color-disabled);
cursor: not-allowed;
}
.ds-pagination__link:focus {
outline: none;
}
:where(.ds-pagination[data-state="focus-visible"], .ds-pagination__item[data-state="focus-visible"]) .ds-pagination__link,
.ds-pagination__link:focus-visible {
outline: var(--pagination-link-outline-width-focus-visible) solid var(--pagination-link-outline-color-focus-visible);
outline-offset: var(--pagination-link-outline-offset-focus-visible);
}
@media (prefers-reduced-motion: reduce) {
.ds-pagination__link {
transition: none;
}
}
packages/components/src/components/pagination/pagination.template.mjs
/**
* Pagination — HTML template for the web implementation.
* Pure function of the spec properties (packages/components/specs/components/pagination.spec.json) -> HTML string.
* A nav landmark labelled "Pagination" holding a list of page links; the current page carries
* aria-current="page" and is not a link; previous and next are links with a chevron and a visible
* label, rendered without a destination (aria-disabled) at the first and last pages.
*
* Extra sandbox-only props: `state` on the root forces a state on every item; an entry's `state`
* forces it on that item.
*/
import { escapeHtml, escapeAttr, renderIcon } from '../../lib/template-utils.mjs';
export const meta = { id: 'pagination', selector: '.ds-pagination' };
export function render(props = {}) {
const {
pages = [{ label: '1', href: '#', current: true }, { label: '2', href: '#' }, { label: '3', href: '#' }],
previous = null,
next = null,
label = 'Pagination',
size = 'medium',
state = null,
} = props;
const rootAttrs = ['class="ds-pagination"', `aria-label="${escapeAttr(label)}"`, `data-size="${escapeAttr(size)}"`];
if (state && state !== 'default') rootAttrs.push(`data-state="${escapeAttr(state)}"`);
const link = (content, href, extra = []) => (href
? `<a class="ds-pagination__link" data-part="link" href="${escapeAttr(href)}" ${extra.join(' ')}>${content}</a>`
: `<span class="ds-pagination__link" data-part="link" role="link" aria-disabled="true" ${extra.join(' ')}>${content}</span>`);
const item = (content, itemState = null, part = 'item') => {
const attrs = [`class="ds-pagination__${part === 'item' ? 'item' : part}"`, `data-part="${part}"`];
if (part !== 'item') attrs[0] = 'class="ds-pagination__item"';
if (itemState && itemState !== 'default') attrs.push(`data-state="${escapeAttr(itemState)}"`);
return `<li ${attrs.join(' ')}>${content}</li>`;
};
const items = [];
items.push(item(link(`<span class="ds-pagination__icon" aria-hidden="true">${renderIcon('chevron_left')}</span><span>Previous</span>`, previous), null, 'previous'));
pages.forEach((entry, i) => {
const { label: text = String(i + 1), href = null, current = false, state: itemState = null } = entry;
const content = current
? `<span class="ds-pagination__link" data-part="link" aria-current="page">${escapeHtml(text)}</span>`
: link(escapeHtml(text), href ?? '#');
items.push(item(content, itemState));
});
items.push(item(link(`<span>Next</span><span class="ds-pagination__icon" aria-hidden="true">${renderIcon('chevron_right')}</span>`, next), null, 'next'));
return `<nav ${rootAttrs.join(' ')}><ul class="ds-pagination__list" data-part="list">${items.join('')}</ul></nav>`;
}
No other platform implementation recorded yet. Platform token outputs live under Platforms.