Pagination
The control that moves between the numbered pages of one list or table whose page count is known. It navigates, which stepper never does, and it moves between pages of data rather than by a carousel page. “Load more”, infinite scroll, the page-size select, and the “1 to 20 of 240” range line all stay with the app.
One component
Pagination is controlled and takes three props: page, counting from 1, pageCount, and onPageChange. There are no parts to assemble — the window is arithmetic the component owns, and the indicator bar needs one owner to slide between items. A pageCount of 1 or less renders nothing, so a one-page list needs no guard around it.
The window
Up to seven pages, every page shows. Above seven the row always holds seven slots, so its width never changes as the page moves and the numbers never shift under the pointer. The window carries one sibling on each side of the current page and one boundary page at each end, neither of them configurable. An ellipsis stands only for a run of at least two hidden pages, so a gap never hides a single page a number could show; it is plain text with no states, not a menu and not a jump control.
On a list of twenty pages that gives 1 2 3 [4] 5 … 20 for pages 1 to 4, 1 … 9 [10] 11 … 20 for pages 5 to 16, and 1 … 16 [17] 18 19 20 for pages 17 to 20. Through the middle range the current page sits in slot 4 — the numbers change around a still bar, and the bar moves only near the two ends. That is the price of a row that keeps one width.
Previous and next are icon-only and disabled at the ends rather than hidden, for the same reason: a row that loses a control is a row that changes width. There are no first and last buttons, because the boundary pages already go there.
Short lists
Five pages fit in the seven slots, so every page shows and no ellipsis appears. Nothing to configure: the same component decides from pageCount.
Compact
Pass compact where the row has no space for numbers: a toolbar, a card footer, a phone-width list. Previous and next keep their size and the numbers give way to the position, Page 3 of 12. Pass formatPageLabel to write that text yourself, for another language or another wording. The component never measures width and has no breakpoint behaviour, so the form is the consumer’s choice.
Links
URL-driven paging passes renderPageLink(page, children). The page items and an enabled previous or next then render as the anchor it returns — a plain <a> or a router’s link — and pagination applies its own classes, aria-current, and aria-label onto that anchor. There is no separate link part to place. A disabled previous or next renders as a plain span that takes no focus, because an <a> cannot be disabled and a tab stop that goes nowhere is worse than none.
The demo below is the real thing: each item is a router link, and the page it shows comes from this page’s own URL. Move it and the address bar moves with it.
Composed under a table
Pagination is the control, not the data. Put it under the table it pages, right-aligned, and keep the row count, the page size, and the fetching in the app — the table page shows the composition.
States and motion
An item rests at --muted-foreground and steps to --foreground on hover and press, colour only, at --motion-fast. There is no background step and no border: the same rule the tabs trigger follows. The current page takes --foreground, medium weight, and the bar. It stays a focusable button or link so that activating it leaves focus where the user put it, and activating it changes nothing else.
The bar is one --indicator element shared through a layoutId on spring-bounce, the marker tabs and sidebar already use, inside a LayoutGroup of its own so two paginations on one page never trade bars. The numbers swap with no animation, and the compact form animates nothing.
Accessibility
The root is a nav named “Pagination”, which aria-label overrides, holding a list of items. Each page item is named “Page 5” and the current one carries aria-current="page"; previous and next are named “Previous page” and “Next page”; the ellipsis is hidden from the accessibility tree; the compact text is a polite live region, so a screen reader hears the new position without being interrupted.
Tab moves through previous, the items, and next, and Enter or Space activates. There are no arrow keys: pagination is a navigation region of independent controls, not a composite widget, and APG defines no arrow-key pattern for it. Items are keyed by page number, so focus stays on the page you activated even when the window shifts around it and you can keep stepping without hunting for focus. Focus shows as the offset ring in --ring.
Items at rest, the ellipsis, and the compact text are --muted-foreground on --background, measured at 4.73:1 in light mode and 7.63:1 in dark; hover and the current page are --foreground. Every pair clears WCAG AA in both modes.