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.
26–50 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.
First
Double-chevron left. Jumps to page 1. Hidden when showFirstLast is false.
Previous
Single-chevron left. Decrements the page by one. Disabled at the first page.
Indicator
“X of Y” counter. Set in tabular numerals so digits don't shift the controls. Wrapped in
aria-live="polite"for screen readers.Next
Single-chevron right. Increments the page by one. Disabled at the last page.
Last
Double-chevron right. Jumps to the final page. Hidden when showFirstLast is false.
Behavior
- Hidden when there's only one page.
totalPages ≤ 1renders 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>witharia-label="Pagination"(override viaariaLabel) 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
disabledattribute 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
Do
Use compact in table footers — paired with a row-count summary on the other edge for symmetry.
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
| Prop | Type | Default | Description |
|---|---|---|---|
| page* | number | — | Current page (1-indexed). Out-of-range values are clamped to the valid range on render. |
| totalPages* | number | — | Total number of pages. The component renders nothing when this is 1 or less. |
| onPageChange* | (next: number) => void | — | Called 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). |
| showFirstLast | boolean | true | Render the First and Last buttons. Disable when the surface is too tight for five controls. |
| labels | PaginationLabels | — | Override default English labels — first, previous, next, last, and the indicator formatter (page, total) => string. |
| ariaLabel | string | "Pagination" | ARIA label for the nav landmark. Override when there are multiple paginations on the same screen. |
| className | string | — | Pass-through class on the wrapper nav element. |