Pagination
Page links for long, stable result sets where position matters.
- Status
- Experimental
- Level
- Molecule
- Category
- Navigation
- Adoption
- Not used yet
import { Pagination } from "@oration/canon/components/pagination";packages/canon/src/components/pagination.tsxRemittance archive
101–125 of 212
- RMT-04311Northwind Freight$18,275.00
- RMT-04310Halcyon$15,155.00
- RMT-04309Orchard Street$12,035.00
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious,} from "@oration/canon/components/pagination";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function Hero() { const total = 212; const perPage = 25; const pageCount = Math.ceil(total / perPage); const [page, setPage] = React.useState(5); const first = (page - 1) * perPage + 1; const last = Math.min(total, page * perPage); const middle = [page - 1, page, page + 1].filter( (n) => n > 1 && n < pageCount, ); const go = (event: React.MouseEvent, next: number) => { event.preventDefault(); setPage(Math.min(pageCount, Math.max(1, next))); }; return ( <div className="flex w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-card text-left shadow-border"> <div className="flex items-center justify-between border-b border-border px-4 py-3"> <p className="text-sm font-semibold">Remittance archive</p> <p className="text-xs text-muted-foreground tabular-nums"> {first}–{last} of {total} </p> </div> <ul className="flex flex-col px-4 text-13"> {[0, 1, 2].map((offset) => ( <li key={offset} className="flex h-9 items-center gap-3 border-b border-border" > <span className="font-mono text-xs"> RMT-{String(4412 - first - offset).padStart(5, "0")} </span> <span className="text-muted-foreground"> { [ "Northwind Freight", "Halcyon", "Orchard Street", ][offset] } </span> <span className="ml-auto tabular-nums"> $ {(18240 - offset * 3120 + page * 7).toLocaleString( "en-US", )} .00 </span> </li> ))} </ul> <Pagination className="py-3"> <PaginationContent> <PaginationItem> <PaginationPrevious href={page > 1 ? `?page=${page - 1}` : undefined} aria-disabled={page === 1 || undefined} className={cn( page === 1 && "pointer-events-none opacity-50", )} onClick={(event) => go(event, page - 1)} /> </PaginationItem> <PaginationItem> <PaginationLink href="?page=1" isActive={page === 1} onClick={(event) => go(event, 1)} > 1 </PaginationLink> </PaginationItem> {page > 3 ? ( <PaginationItem> <PaginationEllipsis /> </PaginationItem> ) : null} {middle.map((n) => ( <PaginationItem key={n}> <PaginationLink href={`?page=${n}`} isActive={n === page} onClick={(event) => go(event, n)} > {n} </PaginationLink> </PaginationItem> ))} {page < pageCount - 2 ? ( <PaginationItem> <PaginationEllipsis /> </PaginationItem> ) : null} <PaginationItem> <PaginationLink href={`?page=${pageCount}`} isActive={page === pageCount} onClick={(event) => go(event, pageCount)} > {pageCount} </PaginationLink> </PaginationItem> <PaginationItem> <PaginationNext href={ page < pageCount ? `?page=${page + 1}` : undefined } aria-disabled={page === pageCount || undefined} className={cn( page === pageCount && "pointer-events-none opacity-50", )} onClick={(event) => go(event, page + 1)} /> </PaginationItem> </PaginationContent> </Pagination> </div> );}Usage#
Pagination is a row of page links for a long, stable result set where position matters: page 4 of the remittance archive should be page 4 tomorrow. Every page is a real link, so it can be bookmarked, shared and opened in a new tab. Oration's grids scroll instead, and the few paged lists use a compact footer of a range, two chevrons and Page 1 of 9. Reach for Pagination only when people need to jump to a specific page; the common mistake is using it where a scrolling grid or a filter would get them there faster.
When to use
- For archives and exports people browse by position: remittance history, audit logs, import results.
- When the page belongs in the URL, so a link to page 4 of a filtered list can be shared.
- When the total is known and stable while someone is paging.
When not to use
- For records people work through. Grids scroll, with sticky headers and a totals footer. Use Data grid
- To find a specific record in a long list. Search or filter narrows faster than paging. Use Search field
- For a small table inside a card or settings section. Show the rows, or a View all link. Use Card table
- For moving through the steps of a flow. Use Stepper
The One Filled Button Rule
The Tabular Figures Rule
Anatomy#
- Navigation. A
navnamed pagination, centered, holding a list with 2px gaps. - Previous. A 32px ghost link with a chevron and the word Previous, which hides below 640px.
- Page link. A 32px square ghost link with the page number.
- Current page. The same square as an outline button with the control shadow, marked
aria-current="page". - Ellipsis. A 32px decorative gap for skipped pages.
- Next. The mirror of Previous, with the chevron after the word.
Examples#
Basic
Previous, the first pages, an ellipsis, the last page and Next. The current page is the outline button; every other page is ghost. Resize below 640px and Previous and Next shrink to chevrons.
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious,} from "@oration/canon/components/pagination";export function Basic() { return ( <Pagination> <PaginationContent> <PaginationItem> <PaginationPrevious href="?page=1" /> </PaginationItem> <PaginationItem> <PaginationLink href="?page=1">1</PaginationLink> </PaginationItem> <PaginationItem> <PaginationLink href="?page=2" isActive> 2 </PaginationLink> </PaginationItem> <PaginationItem> <PaginationLink href="?page=3">3</PaginationLink> </PaginationItem> <PaginationItem> <PaginationEllipsis /> </PaginationItem> <PaginationItem> <PaginationLink href="?page=9">9</PaginationLink> </PaginationItem> <PaginationItem> <PaginationNext href="?page=3" /> </PaginationItem> </PaginationContent> </Pagination> );}Small
size takes any Button size. icon-sm gives 28px squares for footers and dense panels. Keep the real href and prevent the default in onClick to page on the client.
import { Pagination, PaginationContent, PaginationItem, PaginationLink,} from "@oration/canon/components/pagination";import * as React from "react";export function Small() { const [page, setPage] = React.useState(2); return ( <Pagination aria-label="Audit log pages"> <PaginationContent> {[1, 2, 3, 4].map((n) => ( <PaginationItem key={n}> <PaginationLink href={`?page=${n}`} size="icon-sm" isActive={n === page} onClick={(event) => { event.preventDefault(); setPage(n); }} > {n} </PaginationLink> </PaginationItem> ))} </PaginationContent> </Pagination> );}First page
There is no disabled prop. On the first page, drop Previous's href, set aria-disabled and dim it, so it can't be followed to the page you're on.
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious,} from "@oration/canon/components/pagination";export function FirstPage() { return ( <Pagination aria-label="Import result pages"> <PaginationContent> <PaginationItem> <PaginationPrevious aria-disabled="true" className="pointer-events-none opacity-50" /> </PaginationItem> <PaginationItem> <PaginationLink href="?page=1" isActive> 1 </PaginationLink> </PaginationItem> <PaginationItem> <PaginationLink href="?page=2">2</PaginationLink> </PaginationItem> <PaginationItem> <PaginationLink href="?page=3">3</PaginationLink> </PaginationItem> <PaginationItem> <PaginationNext href="?page=2" /> </PaginationItem> </PaginationContent> </Pagination> );}States#
import { PaginationLink } from "@oration/canon/components/pagination";export function StatesMatrix() { const states = [ { label: "Rest", className: "", active: false }, { label: "Hover", className: "bg-muted dark:bg-muted/50", active: false, }, { label: "Focus", className: "border-ring ring-3 ring-ring/40", active: false, }, { label: "Pressed", className: "scale-[0.96]", active: false }, { label: "Current", className: "", active: true }, ]; return ( <div className="grid w-full grid-cols-5 items-center gap-y-3 overflow-x-auto" inert > {states.map((state) => ( <span key={state.label} className="text-center text-xs text-muted-foreground" > {state.label} </span> ))} {states.map((state) => ( <span key={state.label} className="flex justify-center"> <PaginationLink href="?page=4" isActive={state.active} className={state.className} > 4 </PaginationLink> </span> ))} </div> );}| State | Treatment |
|---|---|
| Rest | Ghost: no fill, ink text. |
| Hover | Well Gray fill over 150ms, from Button's ghost variant. |
| Focus visible | An indigo border and a 3px Focus Indigo ring at 40%. |
| Pressed | Scales to 0.96 while held, when motion is allowed. |
| Current | Outline variant: White Plane, hairline border and control shadow, with aria-current="page" and data-active. |
| Unavailable | There is no disabled prop. On the first or last page, drop the href, set aria-disabled and dim Previous or Next yourself. |
Behavior#
- Every item is an anchor styled by Button, so it keeps link semantics: Enter follows it, ⌘-click opens a new tab and the browser shows the URL on hover.
- Put the page in the URL (
?page=4). For client-side paging, keep the realhrefand callevent.preventDefault()inonClickbefore updating state, so middle-click still works. PaginationLinkhas norenderprop, so it can't render a NextLink. Without anonClickhandler, a page change is a full navigation.- Show at most seven slots: first, last, the current page with one neighbour each side, and ellipses for the gaps.
PaginationPreviousandPaginationNexthide their words below 640px and keep theiraria-label, so on phones they become 32px chevron buttons.- After a page change, move focus to the top of the results or announce the new range, so keyboard users aren't left at the bottom of a list that just changed.
Do and don't#
Content#
- Previous and Next stay as single words. Don't write Previous page or Older.
- Ranges use an en dash and of: 26–50 of 212. Say Page 2 of 9 only in compact footers without page links.
- Name the unit when the range stands alone: 26–50 of 212 invoices.
Accessibility#
- The
navis named pagination. Name it more specifically, such as Remittance pages, when a page has more than one. - The current page has
aria-current="page", so screen readers announce it as the current link. - Previous and Next are named Go to previous page and Go to next page, which still contains their visible words.
- The ellipsis is
aria-hidden. Its More pages text is never read, which is fine because it isn't interactive. - Links are 32px squares, above the 24px minimum target.
- An unavailable Previous or Next needs
aria-disabled="true"and nohref, or it remains a working link to the same page.
| Keys | Action |
|---|---|
| Tab | Moves through Previous, the pages and Next. |
| Enter | Follows the focused link. |
Design tokens#
| Token | Used for |
|---|---|
--muted | Hover fill of ghost page links |
--border | Hairline around the current page |
--background | Current page fill |
--foreground | Page numbers |
--ring | Focus border and 3px ring at 40% |
shadow-xs | The control shadow on the current page |
--radius-lg | 10px corners, from Button |
API reference#
Pagination
The landmark.
Other props spread onto <nav>.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | "pagination" | Override it when the page has more than one. |
className | string | No default | Merged after mx-auto flex w-full justify-center. |
PaginationContent
The list of items.
Other props spread onto <ul>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged after flex items-center gap-0.5. |
PaginationItem
One slot.
Other props spread onto <li>.
No props of its own.
PaginationLink
A page link, drawn as a Button.
Other props spread onto <a>.
| Prop | Type | Default | Description |
|---|---|---|---|
isActive | boolean | false | Marks the current page: outline variant, aria-current="page" and data-active. |
size | Button["size"] | "icon" | Any Button size. icon-sm for dense footers. |
href | string | No default | The page's URL. Always set it. |
className | string | No default | Passed to Button, merged after its variant classes. |
PaginationPrevious
The previous-page link.
Other props spread onto PaginationLink.
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | "Previous" | The visible word, hidden below 640px. |
PaginationNext
The next-page link.
Other props spread onto PaginationLink.
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | "Next" | The visible word, hidden below 640px. |
PaginationEllipsis
A 32px decorative gap for skipped pages.
Other props spread onto <span>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged last. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Pagination isn't used anywhere in the product yet. The conversation history and campaign contacts lists hand-roll a compact footer instead: a 12px range, icon-xs chevron buttons and Page 1 of 9. Either adopt the component there or add that compact variant to it.
There is no disabled state. On the first or last page, PaginationPrevious and PaginationNext stay live links unless you remove the href and style them yourself.
PaginationLink spreads its props onto a plain <a> and has no render, so it can't use Next's Link for client-side navigation.
Pagination sets role="navigation" on a nav, which is redundant, and names it with the lowercase pagination.
PaginationEllipsis hides its screen reader text with aria-hidden on the parent, so More pages is dead markup.