Pagination
Numbers with a dot beneath each, weight falling away from the current page and its accent dot; or a folio, the page over its total; the neighbours, by name; every page as a barcode's line; or an alphabet set as one word.
Report an issue with PaginationExample
Numbers, a long run
Folio
Barcode
Neighbours
Thumb index
Current
Pointed at
First page
Folio, last page
Neighbours, pointed at
Barcode, first page
Barcode, pointed at
Thumb index, pointed at N
Install
Run it in a project that has the shadcn CLI set up. It brings the 0nlyType base along.
npx shadcn@latest add https:// thedirectors.agency/ ui/ r/pagination.jsonUsage
"use client"import * as React from "react"import {Pagination,PaginationContent,PaginationEllipsis,PaginationItem,PaginationLink,PaginationNext,PaginationPrevious,paginationRange,} from "@/components/ui/pagination"const pad = (n: number) => String(n).padStart(2, "0")const chapters = ["Halden", "Marram", "Tarn", "Scree", "Corrie"]type Go = (n: number) => { href: string; onClick?: React.MouseEventHandler<HTMLAnchorElement> }type Pager = { page: number; go: Go; total: number }/** The page, and the props that make a link turn to page n. Links stay links: a real href, and a plain click turns the page here. */function usePage(first: number, href = (n: number) => `?page=${n}`): [number, Go] {const [page, setPage] = React.useState(first)const go = (n: number) => ({href: href(n),onClick(e: React.MouseEvent<HTMLAnchorElement>) {if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return // a new tab or window: let the link be a linke.preventDefault()setPage(n)// A step that runs out of pages stops being a link, which drops focus. Keep it in the pager, on the new page.const nav = e.currentTarget.closest("nav")requestAnimationFrame(() => {if (nav && !nav.contains(document.activeElement)) (nav.querySelector<HTMLElement>('[aria-current="page"]') ?? nav.querySelector<HTMLElement>("a[href]"))?.focus()})},})return [page, go]}function Numbers({ page, go, total }: Pager) {return (<Pagination><PaginationContent><PaginationItem><PaginationPrevious {...(page > 1 ? go(page - 1) : {})} /></PaginationItem>{paginationRange(page, total).map((n, i) => (<PaginationItem key={n === "gap" ? `gap-${i}` : n}>{n === "gap" ? <PaginationEllipsis /> : <PaginationLink {...go(n)} aria-label={`Page ${pad(n)}`} isActive={n === page}>{pad(n)}</PaginationLink>}</PaginationItem>))}<PaginationItem><PaginationNext {...(page < total ? go(page + 1) : {})} /></PaginationItem></PaginationContent></Pagination>)}function Folio({ page, go, total }: Pager) {return (<Pagination aria-label="Pages, as a folio"><PaginationContent variant="folio"><PaginationItem><PaginationPrevious {...(page > 1 ? go(page - 1) : {})} /></PaginationItem><PaginationItem><PaginationLink {...go(page)} aria-label={`Page ${pad(page)} of ${total}`} isActive>{pad(page)}</PaginationLink></PaginationItem><PaginationItem aria-hidden="true">{pad(total)}</PaginationItem><PaginationItem><PaginationNext {...(page < total ? go(page + 1) : {})} /></PaginationItem></PaginationContent></Pagination>)}function Barcode({ page, go, total }: Pager) {return (<Pagination aria-label="Pages, as a barcode" className="w-[min(30rem,100%)] min-w-56"><PaginationContent variant="barcode"><PaginationItem><PaginationPrevious {...(page > 1 ? go(page - 1) : {})} /></PaginationItem>{Array.from({ length: total }, (_, i) => i + 1).map((n) => (<PaginationItem key={n}><PaginationLink {...go(n)} aria-label={`Page ${pad(n)} of ${total}`} isActive={n === page}>{pad(n)}</PaginationLink></PaginationItem>))}<PaginationItem><PaginationNext {...(page < total ? go(page + 1) : {})} /></PaginationItem></PaginationContent></Pagination>)}const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("")const filled = new Set("ABCDEFGHIKLMNOPRSTW".split("")) // the letters a glossary has entries underfunction Thumb({ at, go = () => ({ href: "?letter" }), force }: { at: string; go?: (l: string) => { href: string; onClick?: React.MouseEventHandler<HTMLAnchorElement> }; force?: string }) {return (<Pagination aria-label="Glossary, by letter" className="max-w-full"><PaginationContent variant="thumb">{letters.map((l) =>filled.has(l) ? (<PaginationItem key={l}><PaginationLink {...go(l)} aria-label={`Letter ${l}`} isActive={l === at} data-force={l === "N" ? force : undefined}>{l}</PaginationLink></PaginationItem>) : (<PaginationItem key={l} aria-hidden="true">{l}</PaginationItem>),)}</PaginationContent></Pagination>)}function Neighbours({ at, go = () => ({ href: "?chapter" }), force }: { at: number; go?: Go; force?: string }) {return (<Pagination aria-label="Chapters" className="w-[min(28rem,100%)]"><PaginationContent variant="neighbours" className="w-full">{at > 0 && <PaginationItem><PaginationPrevious {...go(at - 1)} data-force={force}>{chapters[at - 1]}</PaginationPrevious></PaginationItem>}{at < chapters.length - 1 && <PaginationItem><PaginationNext {...go(at + 1)}>{chapters[at + 1]}</PaginationNext></PaginationItem>}</PaginationContent></Pagination>)}// One place, set four ways: the numbers, the folio and the barcode share a page, so turning one turns them all.export default function Example() {const [page, go] = usePage(7)const [at, goChapter] = usePage(1, (n) => `?chapter=${chapters[n].toLowerCase()}`)const [letter, goLetter] = usePage(12, (n) => `?letter=${letters[n].toLowerCase()}`)return (<div className="grid w-full justify-items-start gap-12"><div className="grid justify-items-start gap-3"><span className="ot-label">Numbers, a long run</span><Numbers page={page} go={go} total={24} /></div><div className="grid justify-items-start gap-3"><span className="ot-label">Folio</span><Folio page={page} go={go} total={24} /></div><div className="grid w-full justify-items-start gap-3"><span className="ot-label">Barcode</span><Barcode page={page} go={go} total={24} /></div><div className="grid w-full justify-items-start gap-3"><span className="ot-label">Neighbours</span><Neighbours at={at} go={goChapter} /></div><div className="grid w-full justify-items-start gap-3"><span className="ot-label">Thumb index</span><Thumb at={letters[letter]} go={(l) => goLetter(letters.indexOf(l))} /></div></div>)}const inert = () => ({ href: "?page" })Contract
- Underneath: native links. The current page is a link with
aria-current="page"; a step with nowhere to go is a<span aria-disabled="true">, in rule-strong. - Anatomy:
<nav aria-label="Pages">holding<ul class="ot-pager" data-variant="numbers | folio | neighbours | barcode | thumb">of<li>, each a page link (two-digit numbers), a step (.ot-pager-step, the arrow drawn in CSS with empty alt text), or a gap (.ot-pager-gap, read as "More pages"). - numbers (the default): a 3px dot sits under each number; the current page's dot is 6px and in the accent, and it lands with spiccato. Pointing at another page draws a hairline ring there.
- Weight falls with distance (600, then 350 for the neighbours, then 200), after the WOVE poster. Colour never drops below pencil, so contrast holds at AA.
- Long runs:
paginationRange(current, total, siblings = 1)gives the ends, the current page and its siblings, and a gap for each run left out, always in the same number of slots (siblings * 2 + 5). A gap has the same outer width as a two-digit page link, two figures plus 7px padding on each side, and never stands for a single page, so paging moves nothing beside the pager. - Below 40rem the numbers drop a size to
p, use a space-1 gap between the padded targets, and the steps keep only their arrows (their words stay for screen readers), so a long run holds one line on a phone. Number links and steps keep a minimum 24px target width. - folio: the current page over the total, as a book sets its folio. The number stands in ink at 600 on its accent dot; a leaning hairline that thins at both ends (the fraction's pen stroke); then the total small, upright and a little wide, in graphite. The total is plain, aria-hidden text; the current page's name carries it ("Page 7 of 24"). The steps are arrows alone. It mirrors in RTL, stroke and all.
- neighbours: the pages either side, by name, at the two ends of the line: a small word ("Previous", "Next", at
pp500 in graphite) over the name atmf300 in ink, hung on a hairline arrow as the notes are in "It has to be design." (the arrow input group's drawn shaft and open head,space-6long, pointing the way the step goes and mirrored in RTL). There's no current page in view, so no accent. Pointing thickens the name to 450 at moderato, as nearness does in the numbers, inks the word and the arrow, and steps the arrowspace-1its way. A step with nowhere to go is left out. - barcode: every page a hairline, SPECTRA's barcode read the way the dot calendar reads its days: the pages behind you in ink, the pages ahead in pencil, yours a 3px line in the accent, 26px tall to the others' 16, with its number set small (
pp600, ink) above it. The other numbers are there, transparent, for their names ("Page 9 of 24"); pointing at or focusing a line lifts it to 22px in graphite and shows its number in place of yours, so one number is ever in view. Each hairline is centred in a non-shrinking 24px-wide target, so the pitch is 24px; the steps are arrows alone. When the run is wider than the nav, it scrolls sideways inside it with horizontal overscroll contained, even without a site wrapper. The current page is revealed on mount, page changes and nav resizes by scrolling only the nav horizontally, never moving the page vertically. The run and arrows mirror in RTL. Every page is a link, so Tab walks the run; it suits runs up to about 40. - thumb: a dictionary's thumb index set the way "the uncreative" sets its word: the alphabet as one heavy (700), tight word at
mf, the current letter reversed out of an accent block in paper. Letters with nothing under them stay in the word, thin (200) and pencil, as an aria-hidden item that isn't a link. Pointing sketches the block in rule; choosing inks it in the accent. Each letter is at least 24px wide; below 40rem the word breaks in two rows of 13, so every letter stays a wide enough target. - Forced colours: the dots and lines are drawn in CanvasText and GrayText, the current one in Highlight; the thumb's block is Highlight with HighlightText.
- Keyboard: Tab through the links; Enter follows. Pagers that turn in place (the example) keep focus in the pager when a step runs out, on the current page.
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
PaginationContent variant | "numbers" | "folio" | "neighbours" | "barcode" | "thumb" | "numbers" | numbers: every page shown, a dot under each; on a phone the steps keep only their arrows. folio: the current page over the total, arrows either side. neighbours: the pages either side by name, at the two ends of the line, each hung on a hairline arrow. barcode: every page a hairline, behind you in ink, ahead in pencil, yours tall in the accent with its number above. thumb: letters instead of pages, the alphabet set as one heavy word with the current letter reversed out. |
paginationRange(current, total, siblings?) | (number | "gap")[] | siblings 1 | The pages to show for a long run: the ends, the current page and its siblings, and "gap" where pages are left out. Always the same number of slots, so nothing beside the pager moves as you page. |
PaginationLink href / isActive | string / boolean | A page. isActive sets aria-current="page", which draws the accent dot. Give it an aria-label that holds the number as shown, such as "Page 03" for 03. | |
PaginationLink asChild | boolean | false | Render your own link (Next's Link, say) with the pager's look. |
PaginationPrevious / PaginationNext href | string | Where the step goes. Omit it on the first or last page and the step shows, unavailable. | |
PaginationPrevious / PaginationNext children | ReactNode | "Previous" / "Next" | Pass a neighbour's name instead (Halden). The arrow is drawn for you, and the accessible name gains its direction. |
PaginationEllipsis | span | Pages left out. Read out as "More pages". | |
folio: the total | PaginationItem | In a folio, the item after the current page holds the total as plain text, aria-hidden; the current page's aria-label says it ("Page 7 of 24"). | |
neighbours: the steps | PaginationPrevious / PaginationNext | Pass each neighbour's name; the direction is set small above it. Leave out a step with nowhere to go. | |
barcode: the pages | PaginationLink | Pass every page, no gaps, each named "Page 9 of 24". Only the current number shows; pointing or focus shows another's in its place. Each line has a centred 24px target. Give the nav a width: longer runs scroll sideways inside it, keeping the current page in view without moving the page vertically. Best to about 40 pages. | |
thumb: the letters | PaginationLink / PaginationItem | A letter with entries is a PaginationLink (aria-label "Letter M"). A letter with none is a PaginationItem holding the bare letter, aria-hidden, so it stays in the word but isn't read out or reached. On a phone the word breaks 13 and 13. |
Craft
Where its move comes from
| Component | Reference | Move |
|---|---|---|
| Pager | WOVE | Weight falls with distance |
| Folio pager | A book's folio; the fraction | The page over the total, on a leaning stroke |
| Neighbours pager | End-of-chapter pages; "It has to be design." notes on hairline arrows | The pages either side, named at the two ends, each hung on an arrow |
| Barcode pager | SPECTRA's barcode; "28 December" dots | Every page a line: behind in ink, ahead in pencil, yours in the accent |
| Thumb pager | A dictionary's thumb index; "the uncreative" | The alphabet as one word, the current letter reversed out |
How it moves
| Component | Articulation | What moves |
|---|---|---|
| Pager | Pencil, then ink | A ring on hover; the accent dot lands |
| Neighbours pager | Legato | The name thickens as you point; the word inks and the arrow steps its way |
| Barcode pager | Spiccato | The line you point at lifts and its number takes the place of yours |
| Thumb pager | Pencil, then ink | Pointing sketches the block in rule; the chosen letter is reversed out of the accent |