Composites

Table Pagination

The bar that goes under a Data Table: a page-size select, a record counter, a page counter, and four navigation buttons. Like the table it pairs with, every piece of state is controlled — it slices nothing and fetches nothing.

Paired with a table

57 invoices, sliced client-side. Change the page size and the page resets to 1 — that is your job, not the component's: it clamps what it displays into the valid range but never calls onPageChange on your behalf, so a stale page number in your state would keep slicing the wrong window. The same applies when a filter shrinks total.

InvoiceClientIssuedAmountStatus
INV-2001Aurora LogisticsJun 1, 2026$480Paid
INV-2002Brightline MediaJul 2, 2026$1,451Pending
INV-2003Cormorant FoodsAug 3, 2026$2,422Overdue
INV-2004Delta ShipworksJun 4, 2026$3,393Pending
INV-2005Everest AnalyticsJul 5, 2026$4,364Paid
INV-2006Foundry LabsAug 6, 2026$5,335Pending
INV-2007Granite PartnersJun 7, 2026$6,306Overdue
INV-2008Harbour Freight CoJul 8, 2026$7,277Pending
INV-2009Ironwood StudioAug 9, 2026$8,248Paid
INV-2010Aurora LogisticsJun 10, 2026$9,219Pending
INV-2011Brightline MediaJul 11, 2026$10,190Overdue
INV-2012Cormorant FoodsAug 12, 2026$11,161Pending
INV-2013Delta ShipworksJun 13, 2026$12,132Paid
INV-2014Everest AnalyticsJul 14, 2026$13,103Pending
INV-2015Foundry LabsAug 15, 2026$14,074Overdue
INV-2016Granite PartnersJun 16, 2026$15,045Pending
INV-2017Harbour Freight CoJul 17, 2026$16,016Paid
INV-2018Ironwood StudioAug 18, 2026$16,987Pending
INV-2019Aurora LogisticsJun 19, 2026$17,958Overdue
INV-2020Brightline MediaJul 20, 2026$929Pending

What the component computes for you

There is deliberately no pageCount prop. Two places deriving the same number drift by one on the last record with depressing regularity, and the user-visible symptom is a Next page button that is still enabled after the table has run out of rows. So the bar derives everything from total, pageSize and page:

ts
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)))
const current   = Math.min(Math.max(1, page), pageCount)   // display clamp only
const start     = total === 0 ? 0 : (current - 1) * pageSize + 1
const end       = Math.min(current * pageSize, total)
const isFirst   = current <= 1                             // disables ⏮ and ◀
const isLast    = current >= pageCount                     // disables ▶ and ⏭
  • pageCount is never 0 — an empty table still reads as page 1 of 1 rather than 1 of 0.
  • start collapses to 0 when there is nothing to count, so range() gets (0, 0, 0, unit) and a translation can special-case it.
  • The clamp is presentational. If your state says page 9 of a 3-page list, the bar shows page 3 and enables nothing forward — but your slice is still computed from 9. Clamp your own state too.

Page sizes

DEFAULT_PAGE_SIZE_OPTIONS is [20, 50, 100], fixed at three steps so that every table across the Comitor products offers the same choice. Override it per table with pageSizeOptions, or remove the control with hidePageSize when the size is the system's decision. Keep pageSize inside the option list: the select matches by value, so an unlisted size leaves the trigger blank.

pageSizeOptions={[5, 10, 25]} · unitLabel="invoices"

hidePageSize

total={0} — start collapses to 0 and every button is disabled

Like DEFAULT_TABLE_PAGINATION_LABELS, this constant lives in a module that carries no 'use client' directive. Anything exported from a client module arrives in a Server Component as a client-reference proxy that reads back as undefined — no throw, no warning — and validating a ?size= search param on the server is exactly what you would want this constant for.

Function-valued labels

Eight of this component's ten strings are unreachable by any dedicated prop: the four sr-only button names, the aria-label of the size select, the visible prefix in front of it, and the two counters. Only region and unitLabel have a standalone prop of their own. Without a label bag an English app would pair an already-translated DataTable with a Vietnamese pagination bar — a half-translated screen, which is worse than a consistently Vietnamese one.

Three of those keys — range, pageStatus and pageSizeSelect — are functions, not templates with placeholders. The reason is word order. The Vietnamese default reads 1–20 trên tổng 57 mục, the English one 1–20 of 57 items: the same four values, different order, different glue. A placeholder string would force every call site to concatenate on the application side, which is a reliable way to get the order wrong in the next language. A function hands the translator the raw values and lets it decide everything else — including branching on them, for plural rules or a zero case.

No labels — the package default, Vietnamese

labels={EN_PAGINATION} + unitLabel="invoices"

A single key: labels={{ pageStatus: (p, n) => `${p} / ${n}` }} — the other nine keep their Vietnamese defaults

The three rules, same as every label bag

  • The prop is Partial<>. The component spreads { ...DEFAULT_TABLE_PAGINATION_LABELS, ...labels } on every render, so a single key is a legal override — as the third example above shows.
  • Interpolating keys are functions. range(start, end, total, unitLabel), pageStatus(page, pageCount), pageSizeSelect(unitLabel). The other seven keys are plain strings.
  • A standalone prop beats labels. unitLabel and label are per-content strings that differ from table to table ("invoices", "contacts"), while labels.unitLabel and labels.region are per-language defaults. Set the language bag once at app level and the content prop per table.

Every key of TablePaginationLabels, with the Vietnamese default it replaces:

PropTypeDefaultDescription
regionstring'Phân trang'aria-label of the <nav>. The label prop overrides it per instance.
unitLabelstring'mục'Default counting unit, passed to range() and pageSizeSelect(). The unitLabel prop overrides it per instance.
pageSizePrefixstring'Hiển thị'Text before the size select. Hidden below the sm breakpoint.
pageSizeSelect(unitLabel: string) => stringunit => `Số ${unit} mỗi trang`FUNCTION — aria-label of the size select.
range(start, end, total, unitLabel) => string`${start}–${end} trên tổng ${total} ${unit}`FUNCTION — the record counter. start and end are 1-based; start is 0 when total is 0.
pageStatus(page, pageCount) => string`Trang ${page} / ${pageCount}`FUNCTION — the page counter between the previous and next buttons.
firstPagestring'Trang đầu'sr-only name of the first-page button.
previousPagestring'Trang trước'sr-only name of the previous-page button.
nextPagestring'Trang sau'sr-only name of the next-page button.
lastPagestring'Trang cuối'sr-only name of the last-page button.

Props

PropTypeDefaultDescription
pagerequirednumberCurrent page, counting from 1. Clamped into 1…pageCount for display only.
pageSizerequirednumberRows per page. Keep it inside pageSizeOptions, or the select renders blank.
totalrequirednumberTotal record count AFTER filtering. The page count is derived from it.
onPageChangerequired(page: number) => voidCalled by the four navigation buttons with the page to move to.
onPageSizeChangerequired(pageSize: number) => voidCalled by the size select. Reset page to 1 here — the component will not do it for you.
pageSizeOptionsreadonly number[]DEFAULT_PAGE_SIZE_OPTIONSChoices in the size select. The default is [20, 50, 100].
unitLabelstringlabels.unitLabelWhat this table counts — "invoices", "contacts". Passed into range() and pageSizeSelect(). Beats labels.unitLabel.
hidePageSizebooleanfalseHides the prefix and the size select, leaving the count and the four buttons.
labelstringlabels.regionaria-label of the <nav>. Set it whenever one screen has more than one pagination bar.
labelsPartial<TablePaginationLabels>DEFAULT_TABLE_PAGINATION_LABELSDisplay and screen-reader strings. Vietnamese unless overridden.
classNamestringClasses on the <nav> wrapper.

Accessibility

  • The bar is a <nav> named by label (falling back to labels.region), so it appears in the landmark list. Give each bar its own name once a screen has two paginated tables.
  • Both counters are role="status" live regions. Moving between pages is announced politely without stealing focus from the button that was just pressed — which matters, because that button may become disabled at the end of the range.
  • The four navigation buttons are icon-only, and each carries its name in an sr-only span while the icon itself is aria-hidden. Translate firstPage, previousPage, nextPage and lastPage even though nothing on screen shows them.
  • First and last are hidden sm:inline-flex — on a narrow screen only previous and next remain, so the row never wraps into an unreadable stack. Jumping to the last page is a convenience, not the only path.
  • The buttons are genuinely disabled at the ends of the range, never merely dimmed — so keyboard and screen-reader users are told, rather than shown, that there is nothing further.
  • The size select is a real listbox with an aria-label from pageSizeSelect(unitLabel), because its visible prefix is hidden below the sm breakpoint and cannot be relied on as its name.
  • Both counters use tabular-nums, so the digits do not change width as you page and the buttons beside them stay put.