ComponentsActions & navigationPagination
On this page
Actions & navigation

Pagination released

A pager that owns the current page of a collection and says when the reader moves.

  • Import@pacit/components/pagination
  • Selectorpct-pagination

No APG pattern applies, and none is invented — the reasoning is under Accessibility ↓

Reading page 7 of 20.

pagination.demo.ts — the same file runs here and is highlighted under Code

Start here

Usage

One import per component, and the shortest possible use — the package itself is installed once, on the start page.

Import this component
import { PctPagination } from '@pacit/components/pagination';
ExportedWhat it is
PctPaginationcomponentpct-paginationPagination: a control that owns the current page of a collection and emits when the user moves to another one.
PctPaginationItemtypenumber | 'ellipsis'One entry in the strip the component draws: either a page number to offer, or a gap where a run of pages has been folded away.
<pct-pagination [(page)]="page" [count]="20" />
Examples

Every state, running

Each example is the component itself, not a screenshot. Open the code under any of them.

A long list, folded

Two hundred pages do not fit on a line. siblingCount says how many neighbours the current page keeps, boundaryCount how many pages stay pinned at each end, and the fold between them is an ellipsis the keyboard never stops on.

Page 37 of 200.

Sizes

The same three heights as every control — 28, 36 and 44 px — so a pagination under a table lines up with the table's own toolbar. The smallest still clears the touch-target floor.

API

What it reads, and what it writes back

Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.

Pagination: a control that owns the current page of a collection and emits when the user moves to another one.

Inputs

NameTypeWhat it does
pagetwo-waydefault 1numberThe current page, 1-based. A model, because both directions are ordinary: an application sets it, and a press on a page button writes it back.
countrequirednumberHow many pages there are. Not how many items — see the class note. A count below 1 draws an empty landmark; a count of 1 draws a single current page with both steppers disabled.
siblingCountdefault 1numberHow many page numbers to keep on each side of the current one before folding.
boundaryCountdefault 1numberHow many page numbers to keep pinned at each end of the strip.
disableddefault falsebooleanWhether the whole control is inert — every button carries disabled.
ariaLabeldefault ''stringThe accessible name of the landmark. Overrides texts().paginationLabel, whose job is only to carry a sensible default in the application's language.
controlsdefault ''stringThe id of the region whose content this pager moves through, written as aria-controls on each page button. Optional: when it is empty the attribute is not written, because an aria-controls that resolves to nothing is worse than none.
sizedefault config.defaultSizePctSizeSize; taken from the global configuration by default (req-api-config).

Outputs

None of its own. The native events — (click), (focus), the form's submit — are the platform's, and two-way models above carry the rest.

What it writes on the element

The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.

AttributeBound toNote
classpct-pagination
rolenavigationA navigation landmark and not role="group": moving between pages of a collection is navigation of content whether or not a page boundary is a URL, and a landmark is how a screen-reader user reaches the pager and steps over it. It has to be named — two pagers on one page (above and below a table) are two landmarks, and ariaLabel is what tells them apart.
aria-labelariaLabel() || texts().paginationLabel
data-pct-sizesize()
data-pct-disableddisabled() ? "" : null
Styling

Parts to select, tokens to override

Two ways in, both stable. No class names to depend on.

Parts

In a test, PctPaginationHarness from @pacit/components/testing reads a part by the name below and a state by its attribute — the harness gate holds the names to the package.

PartSelectorWhat it is
ellipsis[data-pct-part="ellipsis"]the fold between distant pages
list[data-pct-part="list"]the list of page buttons
next[data-pct-part="next"]the button to the page after
page[data-pct-part="page"]one page button
previous[data-pct-part="previous"]the button to the page before

Tokens

Every value the component reads, with the light and the dark default. Override a token at any scope and it cascades like any custom property.

TokenStyles
--pct-pagination-item-bg{pct.surface}#ffffff · #0f172aBackground of an item.
--pct-pagination-item-bg-hover{pct.surface-100}#f1f5f9 · #1e293bBackground of an item on hover.
--pct-pagination-item-bg-selected{pct.primary}#2563eb · #60a5faBackground of an item when selected.
--pct-pagination-item-border-selected{pct.primary}#2563eb · #60a5faEdge colour of an item when selected.
--pct-pagination-item-fg{pct.text-muted}#334155 · #e2e8f0Text colour of an item.
--pct-pagination-item-fg-disabled{pct.text-disabled}#64748bText colour of an item while disabled.
--pct-pagination-item-fg-hover{pct.text}#0f172a · #ffffffText colour of an item on hover.
--pct-pagination-item-fg-selected{pct.on-primary}#ffffff · #0f172aText colour of an item when selected.
--pct-pagination-item-font-size{pct.font.size.md}14pxFont size of an item.
--pct-pagination-item-font-size-lg{pct.font.size.lg}16pxFont size of an item at size lg.
--pct-pagination-item-font-size-sm{pct.font.size.sm}13pxFont size of an item at size sm.
--pct-pagination-item-font-weight-selected{pct.font.weight.medium}500Font weight of an item when selected.
--pct-pagination-item-height{pct.control.height.md}36pxHeight of an item.
--pct-pagination-item-height-lg{pct.control.height.lg}44pxHeight of an item at size lg.
--pct-pagination-item-height-sm{pct.control.height.sm}28pxHeight of an item at size sm.
--pct-pagination-item-padding-x{pct.space.3}8pxHorizontal padding of an item.
--pct-pagination-item-padding-x-lg{pct.space.4}12pxHorizontal padding of an item at size lg.
--pct-pagination-item-padding-x-sm{pct.space.2}4pxHorizontal padding of an item at size sm.
--pct-pagination-item-radius{pct.radius.md}8pxCorner radius of an item.
--pct-pagination-item-target-min{pct.target.min}24pxTouch-target floor of an item.
--pct-pagination-list-gap{pct.space.2}4pxGap between page buttons.
--pct-pagination-marker-fg{pct.text-muted}#334155 · #e2e8f0Text colour of the fold's ellipsis between page numbers.

Theming

A brand override in a few declarations. Scope it to a theme attribute, a page or one container — the component never notices.

[data-theme='brand'] {
  --pct-pagination-item-bg-selected: #0f766e;
  --pct-pagination-item-fg-selected: #ffffff;
  --pct-pagination-item-border-selected: #0f766e;
}

Reading page 7 of 20.

Accessibility

Measured, not assumed

Pattern: none — there is no APG pattern for pagination. The shape it follows is the WAI tutorial: a named navigation landmark, a list of controls, and aria-current="page" on the one you are on. Named in the class JSDoc.

Keyboard

keyeffecttest

Empty by design: every control in the strip is a native <button>, so Tab / Shift+Tab move between them and Enter / Space activate — all the platform's (req-api-platform). It is deliberately not a toolbar with a roving tabindex: the buttons are independent destinations, not a set operated as a unit, and a roving index would hide every page number but one from sequential keyboard navigation and from a screen reader's "next focusable". The cost — one tab stop per rendered button — is capped by siblingCount / boundaryCount keeping the strip to about nine numbers plus two steppers.

What is measured, and what is not yet

Every line is a test in three engines or a gate on every commit. The gaps are listed the same way, each with the requirement that will close it.

MeasuredARIA pattern named in the class JSDoclibs/components/pagination/src/pagination.ts
MeasuredKeyboard map tested key by keyapps/sandbox-e2e/src/pagination.spec.ts — 15 cases × 3 engines. The claim gated is "the platform does it": Tab walking every button one stop at a time (['1', '2', '3', 'next']), Enter and Space paging, and a disabled stepper refusing focus outright
Measuredaxe audit on the component's own sandbox viewapps/sandbox-e2e/src/a11y.spec.ts — /pagination in SBX_ROUTES, so the WCAG 2.2 AA sweep and the landmark audit both take it
MeasuredVisual screenshotapps/sandbox-e2e/src/visual.spec.ts — pagination-fold, the strip with both gaps and the current page marked
Measuredforced-colors: active — no state carried by colour alonelibs/components/pagination/src/pagination.scss — the current page has a fill and a text colour of its own, plus aria-current; the @media block is written out rule by rule (lesson-70). The reading: apps/sandbox-e2e/src/forced-colors.spec.ts — the current page comes back Highlight/HighlightText, a plain one CanvasText, a spent stepper GrayText
n/aprefers-reduced-motion — duration from a tokennot applicable — the component has no transition of its own. The chevron rotation is static.
MeasuredTouch target ≥ 24×24 px outrightlibs/components/pagination/src/pagination.scss — min-inline-size / min-block-size are --pct-pagination-item-target-min = {pct.target.min}, the floor outright. The reading: apps/sandbox-e2e/src/pagination.spec.ts, taken at the smallest size on the axis, where the height token comes closest to the floor
MeasuredSize axis aligned to --pct-control-height-*libs/tokens/src/component.pagination.json — item-height / -sm / -lg are {pct.control.height.*}, the token shared with the field and the button; driven by data-pct-size on the host
GapDensity axisnone — gap: the same one every component here has (req-token-density)
MeasuredRTL — no physical properties + a dir="rtl" screenshotlibs/components/pagination/src/pagination.scss — logical properties throughout; the chevron points by a symmetric quarter-turn so it needs no RTL rule. The screenshot: pagination-fold-rtl in visual.spec.ts; the geometry: apps/sandbox-e2e/src/rtl.spec.ts — the two steppers really change sides
MeasuredSSR + hydration with no NG05xxapps/sandbox-e2e/src/hydration.spec.ts — /pagination in SBX_ROUTES
n/aFormsnot applicable — a pager holds no value a form owns and implements no FormValueControl. page is application state, not a field.
MeasuredParts registered in the inventorylibs/components/parts.snapshot.md, tools/check-parts.mjs — the Parts row above
MeasuredTokens registered + an entry in contrast.policy.jsonlibs/tokens/src/contrast.policy.json — six entries; libs/tokens/tokens.snapshot.md
MeasuredStrings through PCT_TEXTSlibs/components/pagination/src/pagination.html reads paginationPrevious / paginationNext at render time; pagination.ts reads paginationLabel in the host binding. Swap test: providePctTexts over all three keys in libs/components/pagination/src/pagination.spec.ts
MeasuredEntrypoint size budgetlibs/components/size.snapshot.md — the ./pagination row
MeasuredScreen-reader logRead 2026-10-12 from docs/acr/at/ — three readers, whole sandbox. The landmark, the list and the current page all arrive: navigation · Pagination · List with 5 items · 1 · button. · (Current page) (Orca), and 1 current page button list 5 items from VoiceOver in the record of 2026-09-16 (543eacde); NVDA reads the others as 2, button and the step controls by their label, Next page, button — a name a reader can act on rather than a glyph. VoiceOver leaves after one stop here, and on 2026-10-12 said only its own hint on the way out
MeasuredA docs page with live examplesapps/sandbox/src/app/views/pagination/ (the sandbox view). The published site: /components/pagination — prerendered, the demo's own source is the code tab
MeasuredUnit + mutationlibs/components/pagination/src/pagination.spec.ts — 36 cases; libs/components/pagination/src/pagination.property.spec.ts — seven laws over the fold, 120 cases each, read from the DOM: the numbers and the folded runs covering 1..count exactly once, the window following the page within siblingCount, boundaryCount pinned at each end in order, a gap never standing for one page, the width the identity min(count, 2b + 2s + 3), and a stepper spent exactly at its end; libs/components/mutation.snapshot.md — pagination.ts at 96.43, the four survivors equivalent (two sets of a value already held, and range's guard that Array.from already keeps). The sweep moved that score by nothing, and the row is the honest case for why a property is not only a mutant-killer: the laws it added say what the two counts MEAN, and Stryker throws no mutant that asks. pagination.types.ts has no row and needs none: it is a type alias, struck from the measurement by the *.types.ts pattern
Evidence

What the repository holds for this component

Read at build time from the tracked snapshots, not typed here.

96.43%mutation score108 of 112 mutants killed
14 × 3e2e cases × enginesplus the shared audits: axe, forced colors, preferences, size, hydration
6colour pairs measuredlight and dark, at every token build
2visual baselinesChromium, the sandbox's own pictures
27elements in the preview8 listeners · 1 render to settle · 5601 µs in jsdom, held exactly but for the clock

Why it is built this way

It is the model-owning pager. page is its value and count is a number of pages; a press emits, it does not navigate. A links pager built on <a href>, whose current page comes from the router, is a different component and stands beside this one when it is written (0048).

Known limitations

  • A page button's accessible name is its number, not "Page N". See the contract note. It is enough inside a named landmark; the richer phrasing waits on a templated text channel.
  • count is pages, never items. The component that knows how to turn a data source into a page count is the one that holds the data source. Deriving Math.ceil(total / pageSize) is one line at the call site, or a job for the future table wrapper.
  • The links pager is a separate component. A pager on <a href> takes its current page from the router and "activate" means navigate — every sentence in 0048 is then false. It is not this component behind a flag.
  • No "jump to page" field, no page-size selector, no "showing 1–20 of 400" summary. Those are compositions around the pager, not the pager. They may arrive as siblings; they are not inputs here.
  • The clamp is written back to the model from an effect. A consumer who sets page out of range sees it snap to the nearest valid page on the next tick. This is a narrow exception to "don't write a model from an effect", taken because a view showing page 10 while the model says 999 is a worse contract.