Components / navigation

Pagination

pageItems is exported from the module but not from the package barrel, which carries one name per file. It is reachable only inside the repo, where its own tests use it.

Rendered from the package · open on its own

Summary

A numbered way through a list that arrives in pieces.

APG patternnone
Built onnative elements
Statusstable
Since0.2.0
<Pagination page={3} pageCount={12} href={(n) => `/work?page=${n}`} />
<Pagination page={3} pageCount={12} onPageChange={setPage} />

Anatomy

A <nav data-se="pagination"> holding a list. Every clickable is an item; the gap is an ellipsis.

PartElementRequired
ellipsis<span>yes
item<a>yes
list<ul>yes

Properties

PropertyValuesDefaultWhat it does
pagenumberrequiredThe current page, 1-based.
pageCountnumberrequiredHow many pages there are in total.
href(page: number) => stringnoneWhere page n points. Given, every item is a link and can be opened in a new tab; omitted, every item is a button. A page that only changes state in place is a button.
onPageChange(page: number, event: React.MouseEvent) => voidnoneCalled with the page that was activated. With href set the link still navigates, so use this for analytics or to intercept.
labelstringPaginationNames the navigation landmark. Change it when a page has more than one — two navigation landmarks are indistinguishable to a screen reader without it.
pageLabel(page: number) => stringnoneThe accessible name of a page item. The visible text is a bare number, which on its own tells a screen reader nothing about what it does.
previousLabelstringPrevious pageThe accessible name of the previous step. Its visible content is an arrow glyph, which announces as nothing.
nextLabelstringNext pageThe accessible name of the next step.
siblingCountnumber1How many pages to show either side of the current one.

States

StateExpressed as
Hover:hover
Currentaria-current
Disableddata-disabled

The current page is aria-current="page". A step that has run out is data-disabled plus aria-disabled.

Behavior

Which numbers are shown is two rules, and both are easy to leave out and look fine until an edge. A windowed list is at most 2 × siblingCount + 5 items, so when the whole run is no longer than that, windowing hides pages while saving no space and everything is shown. And a gap standing for one number is longer than the number — 1 … 3 hides page two and costs more room than printing it, so a one-page gap becomes the page.

A step that has run out is rendered, not removed. Removing it shifts every other control sideways under the reader's pointer between one page and the next.

Accessibility

Links or buttons, and the difference matters. Given href, every item is a link and a reader can open page four in a new tab. Without it, every item is a button. A page that changes the address is a link; a page that only changes state in place is a button. A div with a click handler is neither, and no keyboard reaches it.

The component owns aria-current and nothing else. One item carries it, and the appearance hangs off that same attribute rather than a class — so what a reader is told and what a reader sees cannot disagree.

Every item needs a name a number cannot give. The visible text is 4; the accessible name is "Page 4". Previous and next carry their own names rather than the arrow glyphs, which announce as nothing useful.

The gap is aria-hidden. "Ellipsis" is nothing a reader can act on.

label names the landmark. Pagination above and below a table is two navigation landmarks, indistinguishable without distinct names.

Keyboard

None of its own. Every item is a tab stop; Enter follows a link, Enter or Space presses a button.

Roles, states and properties

AttributeSet by
aria-disabledalways true
aria-hiddenalways true
aria-labelset at render

Focus

A step that has run out is a <span>, not a disabled button, so it is skipped rather than focused and announced as unavailable.

Screen reader

Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.

Contrast and target size

npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.

It sets no target size of its own; anything interactive inside it brings its own.

Tested

  • test/apg/pagination-window.test.jsx
  • test/browser/surfaces.spec.mjs

Known gaps

_No upstream requirement is recorded as not applicable._

Appearance

TokenUsed for
--se-accent-softbackground
--se-border-strongborder-color
--se-border-widthborder
--se-control-h-smblock-size, min-inline-size
--se-duration-instanttransition
--se-ease-standardtransition
--se-leading-uiline-height
--se-radius-mdborder-radius
--se-space-1gap
--se-space-2padding-inline
--se-surface-sunkbackground
--se-textcolor
--se-text-disabledcolor
--se-text-mutedcolor
--se-text-smfont-size
--se-weight-mediumfont-weight

Motion

Background crosses --se-duration-instant on hover. Nothing else.

Content

Numbers, and two words. pageLabel is where a domain term belongs if the pages are not pages — "Sheet 4", "Week 12".

Usage

A feed a reader scrolls is a Feed. Pagination is for when a reader needs to get to a known place and come back to it.

If the total is unknown, a Previous / Next pair without numbers is honest and this component's numbers are not.

Ships with a table or not at all. Pagination on its own, with nothing above it, is a control looking for a list.

Notes

pageItems is exported from the module but not from the package barrel, which carries one name per file. It is reachable only inside the repo, where its own tests use it.