Skip to content

Pagination

Terminal window
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/pagination.json
See it livePaging a long list, a short one, and a wider neighbourhood.→

It does not fetch, and it does not slice. It is told the page and the total and it says which page was asked for — because whether paging means an OFFSET or an Array.prototype.slice is the product’s business, and a component that guesses gets it wrong for the other one.

const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(25)
const [from, to] = pageRange(page, pageSize, total)
<span>{from}-{to} of {total}</span>
<Pagination
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
labels={{ region: 'Pages', previous: 'Previous', next: 'Next', page: (n) => `Page ${n}` }}
/>

The row keeps a steady width. This is the part that takes the arithmetic. A window of a fixed number of pages looks right in the middle and comes up short at the ends, where one of the gaps is not needed — the row loses a place, and the “next” button moves under the pointer as you use it. So the run of pages is grown to whatever fills the row, given how many gaps it has.

A gap never stands for a single page. 1 … 3 hides only page 2, which the row had room for. Near an end the run slides onto the edge instead — and it slides rather than dropping the page, because leaving it out silently is worse than the gap: the row would say 1 is followed by 3 and give no sign that anything was missing.

The gap is not a button and not hidden either. Drawn as a disabled button it would give a keyboard stops that lead nowhere; hidden from a screen reader it would announce a jump from 2 to 40 with no reason for it.

The current page is announced. aria-current="page", not colour alone — and not aria-selected, which belongs to a listbox.

labels is required. These are the only words the control has, and a default here would be English shipped inside a primitive. page takes the number, because “Page 3” is a sentence only the product’s language can build.

How many rows to a page is PageSize, next door. The two usually sit together and are needed apart often enough to be separate: a list that scrolls for ever wants “how many to load at a time” and no page buttons, and a table with a fixed page size wants the buttons and no choice.

The arithmetic is exported for a product that draws its own: pageCount(total, pageSize) is at least 1, because “page 1 of 0” reads as broken; pageRange(page, pageSize, total) gives [from, to] with to clamped, so the last page says what it holds; pageWindow(page, pages, around?) gives the numbers and 'gap' entries the control draws.

Props

Read from the types in ui/pagination.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.

PropTypeDefault
pagerequirednumberThe current page, counting from one. Clamped into range rather than drawing a hole.
pageSizerequirednumberHow many rows a page holds.
totalrequirednumberHow many rows there are altogether - rows, not pages.
onPageChangerequired(page: number) => voidCalled with the page to go to, from the arrows and the numbered buttons.
aroundnumber1How many pages either side of the current one.
labelsrequiredPaginationLabelsWhat a screen reader hears for the region and its buttons. Required rather than defaulted: a default here would be English shipped inside a primitive, and these are the only words the control has.
classNamestring

Accessibility

The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 12 rules applied and passed, and none failed. The accessibility report has every item.

Left to a person, where axe could not decide: label-content-name-mismatch.

History

Each release that changed ui/pagination.tsx - the file a product copies - newest first.

  1. v0.41.0
    • docs read every props table from the component's types ee3b159
  2. v0.21.0
    • feat a table, its pages, and the two ways a cell is written 32dc405