Pagination
Splits a long set of results into pages and lets people move between them predictably, with links they can share and a sense of how much there is.
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
export default function PaginationDemo() {
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=12">12</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext href="?page=3" />
</PaginationItem>
</PaginationContent>
</Pagination>
)
}Anatomy
- 1PreviousGoes back one page. Collapses to its arrow on small screens.
- 2Page linkA real link to a page, ideally encoded in the URL.
- 3Current pageOutlined, and announced as the current page.
- 4EllipsisStands in for skipped pages.
- 5NextGoes forward one page.
Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/pagination.jsonUsage
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="?page=1" />
</PaginationItem>
<PaginationItem>
<PaginationLink href="?page=2" isActive>
2
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext href="?page=3" />
</PaginationItem>
</PaginationContent>
</Pagination>Pages are real links. Put the page number in the URL (?page=3) so results are shareable, bookmarkable, restorable with the back button and indexable by search engines.
Examples
Default
The current page uses isActive, which switches it to the outline style and sets aria-current="page". Previous and Next collapse to their arrows below the sm breakpoint.
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
export default function PaginationDemo() {
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=12">12</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext href="?page=3" />
</PaginationItem>
</PaginationContent>
</Pagination>
)
}Controlled with a page range
For many pages, show the first page, the last page, and the current page with its neighbours; replace gaps with PaginationEllipsis.
Page 7 of 20
"use client"
import * as React from "react"
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
/** First, last, the current page and its neighbours — gaps become ellipses. */
function pageRange(current: number, total: number): (number | "gap")[] {
const pages = new Set([1, total, current - 1, current, current + 1])
const sorted = [...pages]
.filter((p) => p >= 1 && p <= total)
.sort((a, b) => a - b)
return sorted.flatMap((page, i) =>
i > 0 && page - sorted[i - 1] > 1 ? (["gap", page] as const) : [page]
)
}
export default function PaginationControlled() {
const total = 20
const [page, setPage] = React.useState(7)
function go(event: React.MouseEvent, next: number) {
event.preventDefault()
setPage(Math.min(total, Math.max(1, next)))
}
return (
<div className="flex flex-col items-center gap-4">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={`?page=${page - 1}`}
aria-disabled={page === 1}
onClick={(event) => go(event, page - 1)}
/>
</PaginationItem>
{pageRange(page, total).map((item, i) =>
item === "gap" ? (
<PaginationItem key={`gap-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={item}>
<PaginationLink
href={`?page=${item}`}
isActive={item === page}
onClick={(event) => go(event, item)}
>
{item}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
href={`?page=${page + 1}`}
aria-disabled={page === total}
onClick={(event) => go(event, page + 1)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
<p className="text-sm text-muted-foreground tabular-nums">
{`Page ${page} of ${total}`}
</p>
</div>
)
}Compact
When space is tight or the exact page matters less than direction, show the position as text between two icon links. Give each icon link an aria-label.
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
} from "@/components/ui/pagination"
export default function PaginationCompact() {
return (
<Pagination>
<PaginationContent className="gap-3">
<PaginationItem>
<PaginationLink
href="?page=2"
size="icon-sm"
aria-label="Go to previous page"
>
<ChevronLeftIcon />
</PaginationLink>
</PaginationItem>
<PaginationItem className="text-sm text-muted-foreground tabular-nums">
Page <span className="font-medium text-foreground">3</span> of 12
</PaginationItem>
<PaginationItem>
<PaginationLink
href="?page=4"
size="icon-sm"
aria-label="Go to next page"
>
<ChevronRightIcon />
</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}Table footer
Data tables pair pagination with a page-size control and a range summary (26–50 of 237). Reset to page one when the page size changes.
"use client"
import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Pagination,
PaginationContent,
PaginationItem,
} from "@/components/ui/pagination"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const sizes = [
{ label: "10", value: "10" },
{ label: "25", value: "25" },
{ label: "50", value: "50" },
]
export default function PaginationTableFooter() {
const total = 237
const [size, setSize] = React.useState("25")
const [page, setPage] = React.useState(1)
const perPage = Number(size)
const pages = Math.ceil(total / perPage)
const from = (page - 1) * perPage + 1
const to = Math.min(total, page * perPage)
return (
<div className="flex w-full max-w-xl flex-wrap items-center justify-between gap-4 rounded-xl border bg-card px-4 py-3 text-sm shadow-raised">
<div className="flex items-center gap-2 text-muted-foreground">
<span id="rows-per-page">Rows per page</span>
<Select
items={sizes}
value={size}
onValueChange={(value) => {
setSize(value as string)
setPage(1)
}}
>
<SelectTrigger
size="sm"
className="w-18"
aria-labelledby="rows-per-page"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sizes.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<Pagination className="mx-0 w-auto">
<PaginationContent className="gap-3">
<PaginationItem className="text-muted-foreground tabular-nums">
{`${from}–${to} of ${total}`}
</PaginationItem>
<PaginationItem className="flex gap-1">
<Button
variant="outline"
size="icon-sm"
aria-label="Previous page"
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
<ChevronLeftIcon />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label="Next page"
disabled={page === pages}
onClick={() => setPage(page + 1)}
>
<ChevronRightIcon />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)
}Guidelines
When to use
- For result sets people search and filter, and need to return to: tables, admin lists, search results, archives.
- When the position in a set matters, or people need to reach the end without loading everything before it.
When not to use
- For feeds meant to be browsed rather than searched — infinite scroll or a Load more button fits better.
- For fewer than about 25 items — show them all on one page.
- For steps in a process — use a stepper; pages imply equal, independent chunks.
Pagination, load more, or infinite scroll
Behavior
Keep the control in the same place on every page — below the results, and optionally above long ones. Scroll back to the top of the results after changing pages, and keep filters and sorting when the page changes. Show the total when you know it; it helps people decide whether to refine their search.
Accessibility
- Landmark.
Paginationrenders a<nav>labelled Pagination — in the language set by LocaleProvider. If a page has two (above and below results), label them distinctly, such as Pagination, top and Pagination, bottom. - Current page.
isActivesetsaria-current="page", so the current page is announced, not just styled. - Direction links are labelled Go to previous page and Go to next page, even when only their arrows are visible.
- Ellipses hide their icon from assistive technology and are read as More pages instead, in the language set by
LocaleProvider. - Disabled ends. On the first and last page, set
aria-disabledon Previous or Next and keep them in place, so the layout doesn't shift.
| Key | Behavior |
|---|---|
Tab | Moves focus through the page links in order. |
Enter | Follows the focused link. |
API reference
Pagination
The <nav> landmark. Accepts all <nav> props.
PaginationContent and PaginationItem
PaginationContent is the <ul> that lays out items; PaginationItem is each <li>.
PaginationLink
Renders an <a> styled as a prfct Button.
PaginationPrevious and PaginationNext
PaginationEllipsis
Renders a <span> with a ⋯ icon that stands in for skipped pages. The icon is hidden from assistive technology; a visually hidden More pages is read in its place.