Pagination
Fully controlled page controls and page-size select, for a client table or a server cursor.
Page controls plus a page-size select, with no state of its own. The host owns the page, the page size, and the total, and this renders the controls and reports which page was asked for. It is the same split Table uses for sorting, so a TanStack client table, a plain array slice, and a server cursor all drive it the same way. There is no table library in here and no data model, which is why it lives under navigation rather than beside the table: it pages anything, a card grid or a search result list as readily as rows.
Invoice | Client | Amount |
|---|---|---|
| INV-1024 | Northwind | $250 |
| INV-1025 | Acme | $387 |
| INV-1026 | Globex | $524 |
| INV-1027 | Initech | $661 |
| INV-1028 | Umbrella | $798 |
Two modes, and the one most components get wrong
Pass totalItems and the page count is derived, the pages are numbered, and the status
reads "Page 3 of 47". That is the client-table case.
Leave totalItems off and there is nothing to number, so the component runs on
hasNextPage instead. A keyset or cursor endpoint knows whether another page exists and
usually does not know how many there are, and the types say so: hasNextPage is required
exactly when totalItems is absent, so you cannot half-configure it.
- INV-1024
- INV-1025
- INV-1026
- INV-1027
hasPreviousPage defaults to page > 1, which is true of most cursors. Pass it when yours
disagrees.
Driving it
The page is 1-based because that is what the controls and the status text say out loud. TanStack counts from 0, so its adapter is one line each way.
<Pagination
page={table.getState().pagination.pageIndex + 1}
pageSize={table.getState().pagination.pageSize}
totalItems={table.getFilteredRowModel().rows.length}
onPageChange={(page) => table.setPageIndex(page - 1)}
pageSizeOptions={[10, 25, 50]}
onPageSizeChange={table.setPageSize}
/>Clamping is yours too. Growing the page size while deep in a list can put you past the
last page, so reset or clamp the page in onPageSizeChange the way the demo above does.
The page-size select ships in the same item because the two controls are one cluster in
every table footer that has them, but it is opt-in: leave off pageSizeOptions or
onPageSizeChange and only the page controls render.
Accessibility
The root is a <nav> landmark named from label, so give each one a distinct name when a
page has two. Every control is a real <button>: in tab order, activating on Enter and
Space, and named for what it does, so a screen reader hears "Go to page 3" rather than a
bare number. The current page carries aria-current="page", and adds an underline under
forced-colors where the fill is dropped.
Previous and next are genuinely disabled at the ends, not dimmed. Because disabling the
button you just pressed would drop focus to the body, focus moves to the control still
live on the other side.
The status line is a role="status" region with aria-atomic, so changing page or page
size announces the whole phrase rather than a loose number. The ellipsis is a decorative
glyph with an sr-only "More pages" beside it.
siblingCount sets how many pages sit either side of the current one before the list
collapses, and defaults to 1. The first and last page are always rendered, so there is
no separate first/last control to get lost in.
Usage
import { Pagination } from '@/components/ui/pagination';
<Pagination
page={page}
pageSize={pageSize}
totalItems={rows.length}
onPageChange={setPage}
pageSizeOptions={[10, 25, 50]}
onPageSizeChange={setPageSize}
label="Invoices"
/>Install
shadcn add @gesso/pagination