Skip to content

Pagination

Page links for long, stable result sets where position matters.

Category
Navigation
Adoption
Not used yet
import { Pagination } from "@oration/canon/components/pagination";
packages/canon/src/components/pagination.tsx

Remittance 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 current page is an outline button and every other page is ghost. Pagination never takes the view's filled indigo button.

The Tabular Figures Rule

Ranges and totals beside pagination, such as 26–50 of 212, are set in 12px tabular figures so they don't jitter as you page.

Anatomy#

  1. Navigation. A nav named pagination, centered, holding a list with 2px gaps.
  2. Previous. A 32px ghost link with a chevron and the word Previous, which hides below 640px.
  3. Page link. A 32px square ghost link with the page number.
  4. Current page. The same square as an outline button with the control shadow, marked aria-current="page".
  5. Ellipsis. A 32px decorative gap for skipped pages.
  6. 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#

RestHoverFocusPressedCurrent44444
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>    );}
States
StateTreatment
RestGhost: no fill, ink text.
HoverWell Gray fill over 150ms, from Button's ghost variant.
Focus visibleAn indigo border and a 3px Focus Indigo ring at 40%.
PressedScales to 0.96 while held, when motion is allowed.
CurrentOutline variant: White Plane, hairline border and control shadow, with aria-current="page" and data-active.
UnavailableThere 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 real href and call event.preventDefault() in onClick before updating state, so middle-click still works.
  • PaginationLink has no render prop, so it can't render a Next Link. Without an onClick handler, 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.
  • PaginationPrevious and PaginationNext hide their words below 640px and keep their aria-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#

Do. Keep the window short: first, last, the current page and its neighbours, with an ellipsis for the gaps.
Don't. Render every page number. Twenty links wrap, and none of them tells you where you are.
26–50 of 212
Do. Pair a paged list with its range in 12px tabular figures: 26–50 of 212.
Don't. Show page numbers with no total, so people can't tell how much is left.

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 nav is 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 no href, or it remains a working link to the same page.
Keyboard interactions
KeysAction
TabMoves through Previous, the pages and Next.
EnterFollows the focused link.

Design tokens#

Design tokens
TokenUsed for
--mutedHover fill of ghost page links
--borderHairline around the current page
--backgroundCurrent page fill
--foregroundPage numbers
--ringFocus border and 3px ring at 40%
shadow-xsThe control shadow on the current page
--radius-lg10px corners, from Button

API reference#

Pagination

The landmark.

Other props spread onto <nav>.

Props of Pagination
PropTypeDefaultDescription
aria-labelstring"pagination"Override it when the page has more than one.
classNamestringNo defaultMerged after mx-auto flex w-full justify-center.

PaginationContent

The list of items.

Other props spread onto <ul>.

Props of PaginationContent
PropTypeDefaultDescription
classNamestringNo defaultMerged after flex items-center gap-0.5.

PaginationItem

One slot.

Other props spread onto <li>.

No props of its own.

PaginationPrevious

The previous-page link.

Other props spread onto PaginationLink.

Props of PaginationPrevious
PropTypeDefaultDescription
textstring"Previous"The visible word, hidden below 640px.

PaginationNext

The next-page link.

Other props spread onto PaginationLink.

Props of PaginationNext
PropTypeDefaultDescription
textstring"Next"The visible word, hidden below 640px.

PaginationEllipsis

A 32px decorative gap for skipped pages.

Other props spread onto <span>.

Props of PaginationEllipsis
PropTypeDefaultDescription
classNamestringNo defaultMerged 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.