Skip to content

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.