Components · Navigation

Pagination

A counter-style pagination — First, Previous, “X of Y”, Next, Last. No numbered page links; the indicator scales to any total without ever wrapping or shifting width. Built for table footers, dialog lists, and dense data surfaces.

Documentedby Derek Fidler

Default — compact, mid-page

Overview

Pagination uses the “page of total” counter pattern, not numbered page links. Five controls — First / Previous / a static “X of Y” indicator / Next / Last — fit any number of pages without changing width. Reach for it on table footers, dialog lists, archive views — anywhere a row of data spans more rows than the viewport can hold.

Why no page numbers

Numbered links (1 2 3 ... 47 48) feel familiar, but they shift width as totals grow, force decisions about how many to show before truncating, and reward users who already know which page they want. The counter pattern stops both the layout and the user from doing busywork — for the rare case where someone needs page 47 directly, give them a search input.

Variants

Two display modes. compact (default) is icon-only — built for table footers where horizontal space is tight. labeled adds the word next to each chevron — used in dialogs and any surface where the buttons should announce their target action without relying on icon literacy.

variant="labeled"

Chevron + label. Reach for it when the surface has room and the buttons should announce their target action.

variant="compact"

Icon-only. Default. Used in dense table footers, dialog footers, and any surface where horizontal space is tight.

States

Bounded — at the first page, First and Previous disable themselves; at the last page, Next and Last disable. The component clamps the page prop so an out-of-range value still renders correctly.

At first page

First and Previous are disabled — there's nowhere to go backward.

Mid-pages

All four navigation buttons are active.

At last page

Next and Last are disabled — the user can only step back.

Try it

Click or tab to the buttons and press Enter / Space to navigate. The indicator is wrapped in an aria-live="polite" region so assistive tech announces the new page on every change.

Open me

Click the controls or tab to them and press Enter / Space. The aria-live indicator announces every page change.

In a table

The most common surface. Pair the pagination with a row-count summary on the opposite edge so the footer reads as a single balanced strip.

2650 of 125 prototypes

In a table footer

Pagination usually sits inside a table's footer row, with a row-count summary on the opposite edge for symmetry.

Localised

Pass a labelsprop to swap each control's text and override the “X of Y” format. Required for any market where en-GB strings aren't the right answer.

Danish (da-DK)

Pass a labels prop with first / previous / next / last and an indicator formatter.

Indicator override

The indicator can be any function — fraction, percent, position phrase. Just keep it short.

Anatomy

Five named parts. The indicator is the only static element — the four buttons sit on either side, equally spaced.

  1. First

    Double-chevron left. Jumps to page 1. Hidden when showFirstLast is false.

  2. Previous

    Single-chevron left. Decrements the page by one. Disabled at the first page.

  3. Indicator

    “X of Y” counter. Set in tabular numerals so digits don't shift the controls. Wrapped in aria-live="polite" for screen readers.

  4. Next

    Single-chevron right. Increments the page by one. Disabled at the last page.

  5. Last

    Double-chevron right. Jumps to the final page. Hidden when showFirstLast is false.

Behavior

  • Hidden when there's only one page. totalPages ≤ 1 renders nothing — never a disabled-everywhere stub. If the data fits on one page, no pagination chrome is the right answer.
  • Page is clamped on render. An out-of-range pageprop (e.g. page 99 of 4) is clamped to the valid range, so the component never gets stuck in an unreachable state if the parent's data shrinks.
  • No keyboard shortcuts beyond Tab. Power users get arrow keys when they tab into a button — that's native browser behaviour. The component doesn't hijack global ←/→ because that conflicts with text fields, tables, and other surfaces.
  • First / Last are opt-out, not opt-in. They ship by default because jumping to the end of long lists is the second-most-common pagination action. Pass showFirstLast={false} when the surface only has room for the three middle controls.

Accessibility

  • Nav landmark. The component renders as a <nav> with aria-label="Pagination" (override via ariaLabel) so screen readers can jump to it as a region.
  • Icon-only buttons announce their action. The compact variant sets aria-labelon each button (“First”, “Previous”, …) so the chevron isn't the only signal.
  • Live indicator.The “X of Y” counter sits in an aria-live="polite" + aria-atomic="true" region — every page change is announced as one statement, not character-by-character.
  • Disabled state at bounds. First / Previous use the native disabled attribute when at page 1; Next / Last when at the final page. Disabled buttons are removed from the tab order — the user doesn't land on a dead control.
  • Tabular numerals. The indicator uses tabular-nums + slashed-zeroso digits don't shift width as the page count climbs from 9 to 10 to 100.

Code

Import from @flatpay-dk/ui. Pagination is a controlled component — you own the page state and pass it back via onPageChange.

tsx

import { Pagination } from "@flatpay-dk/ui";

// Default — compact, with First / Last
const [page, setPage] = useState(1);

<Pagination
  page={page}
  totalPages={Math.ceil(rows.length / pageSize)}
  onPageChange={setPage}
/>

// Labeled — chevron + text
<Pagination
  page={page}
  totalPages={4}
  variant="labeled"
  onPageChange={setPage}
/>

// Without First / Last
<Pagination
  page={page}
  totalPages={totalPages}
  showFirstLast={false}
  onPageChange={setPage}
/>

// Localised — pass labels
<Pagination
  page={page}
  totalPages={totalPages}
  onPageChange={setPage}
  labels={{
    first: "Første",
    previous: "Forrige",
    next: "Næste",
    last: "Sidste",
    indicator: (p, t) => `${p} af ${t}`,
  }}
/>

// In a table footer with row-count
<div className="flex items-center justify-between">
  <p className="font-mono text-[12px] text-muted-foreground">
    {(page - 1) * 25 + 1}–{Math.min(page * 25, total)} of {total}
  </p>
  <Pagination
    page={page}
    totalPages={Math.ceil(total / 25)}
    onPageChange={setPage}
  />
</div>

Best practices

1–25 of 125

Do

Use compact in table footers — paired with a row-count summary on the other edge for symmetry.

1–8 of 8

Don't

Don't render Pagination when there's only one page. The component already returns null, but don't compute it twice.

Do

Use labeled when there's room — explicit text reads faster than chevron literacy on first encounter.

Don't

Don't combine pagination with a 'Go to page' input on the same row. If users need direct jumps, give them search instead.

Props

PropTypeDefaultDescription
page*numberCurrent page (1-indexed). Out-of-range values are clamped to the valid range on render.
totalPages*numberTotal number of pages. The component renders nothing when this is 1 or less.
onPageChange*(next: number) => voidCalled with the next page when the user navigates. The component is fully controlled — owner manages state.
variant"compact" | "labeled""compact"compact is icon-only (table footers); labeled adds chevron + text (dialogs, marketing).
showFirstLastbooleantrueRender the First and Last buttons. Disable when the surface is too tight for five controls.
labelsPaginationLabelsOverride default English labels — first, previous, next, last, and the indicator formatter (page, total) => string.
ariaLabelstring"Pagination"ARIA label for the nav landmark. Override when there are multiple paginations on the same screen.
classNamestringPass-through class on the wrapper nav element.