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.
| Invoice | Client | Issued | Amount | Status |
|---|---|---|---|---|
| INV-2001 | Aurora Logistics | Jun 1, 2026 | $480 | Paid |
| INV-2002 | Brightline Media | Jul 2, 2026 | $1,451 | Pending |
| INV-2003 | Cormorant Foods | Aug 3, 2026 | $2,422 | Overdue |
| INV-2004 | Delta Shipworks | Jun 4, 2026 | $3,393 | Pending |
| INV-2005 | Everest Analytics | Jul 5, 2026 | $4,364 | Paid |
| INV-2006 | Foundry Labs | Aug 6, 2026 | $5,335 | Pending |
| INV-2007 | Granite Partners | Jun 7, 2026 | $6,306 | Overdue |
| INV-2008 | Harbour Freight Co | Jul 8, 2026 | $7,277 | Pending |
| INV-2009 | Ironwood Studio | Aug 9, 2026 | $8,248 | Paid |
| INV-2010 | Aurora Logistics | Jun 10, 2026 | $9,219 | Pending |
| INV-2011 | Brightline Media | Jul 11, 2026 | $10,190 | Overdue |
| INV-2012 | Cormorant Foods | Aug 12, 2026 | $11,161 | Pending |
| INV-2013 | Delta Shipworks | Jun 13, 2026 | $12,132 | Paid |
| INV-2014 | Everest Analytics | Jul 14, 2026 | $13,103 | Pending |
| INV-2015 | Foundry Labs | Aug 15, 2026 | $14,074 | Overdue |
| INV-2016 | Granite Partners | Jun 16, 2026 | $15,045 | Pending |
| INV-2017 | Harbour Freight Co | Jul 17, 2026 | $16,016 | Paid |
| INV-2018 | Ironwood Studio | Aug 18, 2026 | $16,987 | Pending |
| INV-2019 | Aurora Logistics | Jun 19, 2026 | $17,958 | Overdue |
| INV-2020 | Brightline Media | Jul 20, 2026 | $929 | Pending |
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:
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 ⏭pageCountis never 0 — an empty table still reads as page 1 of 1 rather than 1 of 0.startcollapses to 0 when there is nothing to count, sorange()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.unitLabelandlabelare per-content strings that differ from table to table ("invoices", "contacts"), whilelabels.unitLabelandlabels.regionare 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:
| Prop | Type | Default | Description |
|---|---|---|---|
region | string | 'Phân trang' | aria-label of the <nav>. The label prop overrides it per instance. |
unitLabel | string | 'mục' | Default counting unit, passed to range() and pageSizeSelect(). The unitLabel prop overrides it per instance. |
pageSizePrefix | string | 'Hiển thị' | Text before the size select. Hidden below the sm breakpoint. |
pageSizeSelect | (unitLabel: string) => string | unit => `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. |
firstPage | string | 'Trang đầu' | sr-only name of the first-page button. |
previousPage | string | 'Trang trước' | sr-only name of the previous-page button. |
nextPage | string | 'Trang sau' | sr-only name of the next-page button. |
lastPage | string | 'Trang cuối' | sr-only name of the last-page button. |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
pagerequired | number | — | Current page, counting from 1. Clamped into 1…pageCount for display only. |
pageSizerequired | number | — | Rows per page. Keep it inside pageSizeOptions, or the select renders blank. |
totalrequired | number | — | Total record count AFTER filtering. The page count is derived from it. |
onPageChangerequired | (page: number) => void | — | Called by the four navigation buttons with the page to move to. |
onPageSizeChangerequired | (pageSize: number) => void | — | Called by the size select. Reset page to 1 here — the component will not do it for you. |
pageSizeOptions | readonly number[] | DEFAULT_PAGE_SIZE_OPTIONS | Choices in the size select. The default is [20, 50, 100]. |
unitLabel | string | labels.unitLabel | What this table counts — "invoices", "contacts". Passed into range() and pageSizeSelect(). Beats labels.unitLabel. |
hidePageSize | boolean | false | Hides the prefix and the size select, leaving the count and the four buttons. |
label | string | labels.region | aria-label of the <nav>. Set it whenever one screen has more than one pagination bar. |
labels | Partial<TablePaginationLabels> | DEFAULT_TABLE_PAGINATION_LABELS | Display and screen-reader strings. Vietnamese unless overridden. |
className | string | — | Classes on the <nav> wrapper. |
Accessibility
- The bar is a
<nav>named bylabel(falling back tolabels.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-onlyspan while the icon itself isaria-hidden. TranslatefirstPage,previousPage,nextPageandlastPageeven 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
disabledat 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-labelfrompageSizeSelect(unitLabel), because its visible prefix is hidden below thesmbreakpoint 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.