TablePagination

Table footer: a status line on the left, Previous and Next on the right. It computes the row range from the current page and announces the new range when the user pages.

Spec · from metadata

When to use

  • A table paged with Previous/Next controls
  • A list view that reports how many rows are showing out of a total
  • Any table footer where the status text changes as the user pages

When not to use

  • Numbered page links, or first/last jumps — use the `Pagination` primitive
  • Infinite scroll or a load-more button — those are not pages
  • A table small enough to fit on one page; the footer hides its controls automatically, but it is simpler not to render it at all

Anti-patterns

Avoid<div className="flex items-center justify-between py-4">
  <span className="text-muted-foreground text-sm">
    Showing {(page - 1) * size + 1} to {Math.min(page * size, total)} of {total}
  </span>
  <div className="space-x-2">
    <Button variant="outline" size="sm">Previous</Button>
    <Button variant="outline" size="sm">Next</Button>
  </div>
</div>
Prefer<TablePagination page={page} pageSize={size} total={total} onPageChange={setPage} />

The range arithmetic is easy to get subtly wrong (off-by-one on the first index, and the last page needs clamping), and the hand-rolled version has no live region, so the status text changes silently when the user pages.

Avoid<TablePagination page={0} pageSize={10} total={100} onPageChange={setPage} />
Prefer<TablePagination page={1} pageSize={10} total={100} onPageChange={setPage} />

`page` is 1-based. A 0-based index renders a range starting at -9.

Accessibility

  • Required ARIAaria-livearia-atomic
  • Min touch target48px
  • Screen readerThe status line is a polite, atomic live region, so paging announces the whole new range rather than the individual numbers that changed. Buttons are disabled rather than hidden at the ends of the range, so their position does not shift.
  • ContrastStatus text uses text-muted-foreground; buttons inherit the outline variant's border and text tokens.

Token bindings

TokenCategoryUsage
muted-foregroundcolorStatus line text
4 (16px)spacingFooter vertical padding (py-4)
text-smtypographyStatus line scale
Recent changes
  • new

    TablePagination compound

    2026-08-12

Import

import { TablePagination } from "@timelycare/helix-ui"

Props

interface TablePaginationProps {
  page: number                 // 1-based
  pageSize: number
  total: number
  onPageChange: (page: number) => void
  hasPrev?: boolean            // override the derived guard
  hasNext?: boolean            // override the derived guard
  label?: (range: { start: number; end: number; total: number }) => React.ReactNode
  previousLabel?: React.ReactNode   // default "Previous"
  nextLabel?: React.ReactNode       // default "Next"
  className?: string
}

Usage

<Table>{/* ... */}</Table>
<TablePagination
  page={page}
  pageSize={10}
  total={total}
  onPageChange={setPage}
/>

Renders nothing when total is 0, and hides the buttons when everything fits on one page, so it is safe to render unconditionally under a table.

Selection count instead of a range

<TablePagination
  page={page}
  pageSize={10}
  total={total}
  onPageChange={setPage}
  label={() => `${selected.length} of ${total} row(s) selected.`}
/>

Inside a card

The default spacing is py-4, matching the footer documented on the DataTable page. When the footer sits inside a container that owns its own padding, override it:

<TablePagination className="px-6 pt-6" … />

Structure

ElementTailwind
Rootflex items-center justify-between py-4
Status linetext-muted-foreground text-sm + role="status" aria-live="polite" aria-atomic="true"
Button groupspace-x-2
ButtonsButton variant="outline" size="sm"

Why a component rather than a copy-paste footer

The DataTable page documents this footer as markup you assemble. Shipping it removes two recurring problems:

  • The range arithmetic is easy to get subtly wrong. The first index is off by one if you forget the + 1, and the last page needs clamping against the total.
  • The hand-rolled version is silent. Paging changes the status text, but nothing announces it. Here the status line is a polite, atomic live region, so the whole new range is read out rather than nothing at all.

Choosing a Pagination Style

SituationUse
Previous/Next with a row rangeTablePagination
Numbered page links, first/last jumpsPagination
Load more / infinite scrollA single Button; these are not pages

Accessibility

  • The status line is role="status" with aria-live="polite" and aria-atomic="true", so a page change announces the complete range instead of the digits that changed.
  • Buttons are disabled rather than hidden at the ends of the range, so their position does not shift under the pointer or in the tab order.
  • Focus stays on the pressed button across a page change, so paging repeatedly by keyboard does not require re-finding the control.
  • Pass previousLabel, nextLabel and label already translated; the library does no i18n.

Gotchas

ProblemSolution
Range starts at -9page is 1-based; a 0-based index was passed
Buttons never enableCursor-based API with an unknown total; drive them with hasPrev / hasNext
Range announced twiceThe status line is already a live region; remove the outer one
Footer padding fights the cardOverride with className; the default py-4 assumes the footer owns its spacing
No rows-per-page selectorNot included; add one beside the component if a product needs it

Related