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.
Summary
A numbered way through a list that arrives in pieces.
| APG pattern | none |
| Built on | native elements |
| Status | stable |
| Since | 0.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.
| Part | Element | Required |
|---|---|---|
ellipsis | <span> | yes |
item | <a> | yes |
list | <ul> | yes |
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
page | number | required | The current page, 1-based. |
pageCount | number | required | How many pages there are in total. |
href | (page: number) => string | none | Where 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) => void | none | Called with the page that was activated. With href set the link still navigates, so use this for analytics or to intercept. |
label | string | Pagination | Names 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) => string | none | The 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. |
previousLabel | string | Previous page | The accessible name of the previous step. Its visible content is an arrow glyph, which announces as nothing. |
nextLabel | string | Next page | The accessible name of the next step. |
siblingCount | number | 1 | How many pages to show either side of the current one. |
States
| State | Expressed as | |
|---|---|---|
| Hover | :hover | |
| Current | aria-current | |
| Disabled | data-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
| Attribute | Set by |
|---|---|
aria-disabled | always true |
aria-hidden | always true |
aria-label | set 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.jsxtest/browser/surfaces.spec.mjs
Known gaps
_No upstream requirement is recorded as not applicable._
Appearance
| Token | Used for |
|---|---|
--se-accent-soft | background |
--se-border-strong | border-color |
--se-border-width | border |
--se-control-h-sm | block-size, min-inline-size |
--se-duration-instant | transition |
--se-ease-standard | transition |
--se-leading-ui | line-height |
--se-radius-md | border-radius |
--se-space-1 | gap |
--se-space-2 | padding-inline |
--se-surface-sunk | background |
--se-text | color |
--se-text-disabled | color |
--se-text-muted | color |
--se-text-sm | font-size |
--se-weight-medium | font-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.