Gesso
Navigation

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-1024Northwind$250
INV-1025Acme$387
INV-1026Globex$524
INV-1027Initech$661
INV-1028Umbrella$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

On this page