Components/Navigation
Pagination
Pagination lets a reader move through a long list one page at a time. It shows where they are in the run and how much of it there is, so a list too long to show at once stays something they navigate deliberately rather than by scrolling and hoping.
import { Pagination } from '@growth-protocol-ai/alpha';When to use
Reach for pagination when the run has a known end and the reader benefits from knowing how far into it they are — a table of results, an archive, a report split across pages. Put one below the rows it pages and let the numbers carry the whole job; the habit that most often goes wrong is a second copy above the list, because two controls reporting one position are only ever in step by accident.
When not to use
Do not reach for it where the run has no end to pin. A cursor-backed feed or a stream has no last page, so there is nothing to draw, and what that wants is a Button the reader presses to fetch more. The subtler mistake is Tabs, which also swaps what is shown from a row of clickable items — the difference is that a tab is a named place the reader chooses between, where a page is a position in one long thing they are working through. If the items have names rather than numbers, they are tabs.
| State | When to use |
|---|---|
A run short enough to show whole | The window and the two pinned ends between them cover every page, so no … appears at any position. A run this short is also the one case where the row keeps a single width the whole way through — walk it and nothing changes but which number is bold and whether an arrow is spent. |
A long run, mid-way | The window is the current page and its siblings; the first and last sit outside it and are always drawn, and a … stands wherever that skips something. With a gap open on either side this is the row at its widest, which is the arrangement a container has to be able to take. |
At either end | The arrow with nowhere to go is disabled outright rather than painted to look it, and the gap on that side has closed — so the row is narrowest exactly where the reader arrives at it and widens as they move in. The two ends are mirror images, shown together because what is worth comparing is the spent arrow. |
A narrower window | Lowering siblings brings the run of numbers either side of the current page down, which is how the widest arrangement is made to fit a column that cannot take the default. Nothing else moves: the ends stay pinned and a gap still stands for whatever is skipped. |
One page, or none | Below two pages the control renders no element at all — not an empty row — so no call site needs a guard of its own. A single page of results is just results. |
The row is a nav landmark named "Pagination" unless you say otherwise, each number is a button announced as the page it goes to, and the arrows are named by previousLabel and nextLabel because their glyphs say nothing at all to a screen reader.
A page is identified by its 1-based number and nothing else — there is no key and no 0-based mode. Adapting from a table library that counts from zero is two expressions at the call site, page={pageIndex + 1} and onPageChange={(p) => gotoPage(p - 1)}, which is safer than a prop that would make the numbers on screen mean two things.
The page the reader is on carries aria-current="page" and is drawn bold in --primary, and it is the one number the pointer does not treat as a target. It is not disabled, though: clicking it reports that same page again, so a handler that refetches on every report should compare against the page it already holds.
Every number and both arrows are real buttons, so Tab walks the row in order and Enter or Space commits. There is no roving tab index and the arrow keys do nothing: this is a row of links to pages rather than a composite widget, and the native focus order is already the right one.
It needs a total. Pagination can only be drawn when the number of pages is known, so a cursor-backed or streaming list has no last page to pin and wants a load-more button or infinite scroll instead.
One pager per list, and below the list it pages. A copy above as well is two controls reporting one position, and they are only ever in step by accident.
The row changes width as the reader moves through it — narrowest at the ends, widest in the middle where both gaps are open. Do not size a container to one of those states; give it a row that can take the widest, or bring siblings down until the widest fits.
The … is not a control. It marks the pages the window skips, and the way to reach them is the numbers either side of it, or the arrows.
A step that reaches an end spends its own arrow: the button disables under the reader’s finger and focus falls back to the document rather than staying where it was. That is expected, and it is why every number is a tab stop of its own instead of sitting behind a roving index.
Controlled page
page with onPageChange, when the position lives in your state rather than the control’s. The callback hands back the page the reader asked for and nothing else, so a controlled pager is two lines and needs no branch on how they got there. A value outside the run is clamped rather than honoured, which is what stops a stale page number surviving a filter and producing a window that skips the page it is on.
- Arden Metals
- Brightwater Chemical
- Calder Precision
| Prop | Type | Default | Notes |
|---|---|---|---|
count | number | — | How many pages there are. Required, because the window is measured from both ends and cannot be drawn without it. At one or fewer the whole control renders nothing. |
page | number | — | Controlled current page, 1-based. Pass it with onPageChange when the position lives in your state; leave it off and the control keeps its own. A value outside the run is clamped rather than honoured. |
defaultPage | number | 1 | The page to start on when the control is uncontrolled. |
onPageChange | (page: number) => void | — | The 1-based page the reader asked for. Fires for a number, for either arrow, and for Enter or Space on any of them; nothing distinguishes the three, because a page change is a page change. |
siblings | number | 2 | How many pages sit either side of the current one. The first and last are outside this count and always drawn, and a … stands wherever the window skips something. Raising it widens the row rather than adding a second line. |
previousLabel | string | "Previous page" | Accessible name for the step back. The arrow is the only content, so this is the whole of what a screen reader announces. |
nextLabel | string | "Next page" | The same, for the step forward. |
aria-label | string | "Pagination" | Names the nav landmark the row sits in. Worth setting wherever a page carries more than one pager, so a landmark list tells them apart rather than offering the same name twice. |
children | never | — | There is nothing to put inside. The row is drawn from count, and what is being paged goes above the control rather than in it. |