Components
Pagination with page navigation, next and previous links. Anchor-based so every page is a real, shareable URL.
Playground
PaginationItems inside PaginationContent. isActive marks the current page — it switches the link to the outline button variant and sets aria-current="page". PaginationEllipsis stands in for collapsed ranges.import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@workspace/ui/components/pagination"
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" />
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">1</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#" isActive>
2
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">3</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
<PaginationItem>
<PaginationNext href="#" />
</PaginationItem>
</PaginationContent>
</Pagination>PaginationPrevious and PaginationNext when space is tight or the list of pages is short.<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationLink href="#">1</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#" isActive>
2
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">3</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>PaginationLinks — the default size="icon" fits a single caret. Keep an aria-label on each and rtl:rotate-180 on the caret so the arrows mirror in RTL.import { CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react"
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationLink href="#" aria-label="Go to previous page">
<CaretLeftIcon className="rtl:rotate-180" />
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">1</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#" isActive>
2
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">3</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#" aria-label="Go to next page">
<CaretRightIcon className="rtl:rotate-180" />
</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>mx-auto w-full defaults with mx-0 w-auto.Page 3 of 12
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<Label htmlFor="rows-per-page" className="text-sm font-normal">
Rows per page
</Label>
<Select defaultValue="25">
<SelectTrigger id="rows-per-page" size="sm" className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-4">
<p className="text-sm text-muted-foreground">Page 3 of 12</p>
<Pagination className="mx-0 w-auto">
<PaginationContent>
<PaginationItem>
<PaginationLink href="#" aria-label="Go to previous page">
<CaretLeftIcon className="rtl:rotate-180" />
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#" aria-label="Go to next page">
<CaretRightIcon className="rtl:rotate-180" />
</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>onClick. Anchors have no disabled attribute — disable the bounds with aria-disabled plus pointer-events-none opacity-50.const totalPages = 5
const [page, setPage] = React.useState(2)
const goTo = (target: number) => (event: React.MouseEvent) => {
event.preventDefault()
setPage(Math.min(Math.max(target, 1), totalPages))
}
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
aria-disabled={page === 1 || undefined}
className={page === 1 ? "pointer-events-none opacity-50" : undefined}
onClick={goTo(page - 1)}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, index) => index + 1).map(
(target) => (
<PaginationItem key={target}>
<PaginationLink
href="#"
isActive={target === page}
onClick={goTo(target)}
>
{target}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
href="#"
aria-disabled={page === totalPages || undefined}
className={
page === totalPages ? "pointer-events-none opacity-50" : undefined
}
onClick={goTo(page + 1)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>dir="rtl" on the root (or inherit it from the page). The caret icons flip via rtl:rotate-180 and the paddings use logical properties, so previous still points toward the start of the reading direction. The text prop swaps the labels.<Pagination dir="rtl">
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" text="السابق" />
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">١</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#" isActive>
٢
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#">٣</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext href="#" text="التالي" />
</PaginationItem>
</PaginationContent>
</Pagination>PaginationLink renders a native anchor, so URL-driven pagination works out of the box: point href at the page's query string and read searchParams in the server component. Every page stays a crawlable, shareable URL — no client state needed.export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>
}) {
const page = Number((await searchParams).page ?? 1)
return (
<PaginationItem>
<PaginationLink href={`?page=${page}`} isActive={page === current}>
{page}
</PaginationLink>
</PaginationItem>
)
}<nav aria-label="pagination"> landmark wrapping a semantic list, so screen readers announce and skip it as a unit. isActive sets aria-current="page", the ellipsis is aria-hidden with an sr-only "More pages" fallback, and the previous / next controls carry explicit labels. Their text labels hide below sm, falling back to the carets.<PaginationPrevious
href="#"
aria-disabled={isFirstPage || undefined}
className={isFirstPage ? "pointer-events-none opacity-50" : undefined}
/>nav, ul, li, a and span — so anchors accept href, target and friends directly. Links are Buttons under the hood via asChild.| Component | Prop | Default |
|---|---|---|
| PaginationLink | isActive?: boolean — outline variant + aria-current | false |
| PaginationLink | size?: Button size | "icon" |
| PaginationPrevious | text?: string — label next to the caret | "Previous" |
| PaginationNext | text?: string — label next to the caret | "Next" |
| PaginationPrevious / Next | size?: Button size | "default" |
| PaginationEllipsis | extends span — aria-hidden, non-interactive | — |
<Pagination data-slot="pagination" /> <PaginationContent data-slot="pagination-content" /> <PaginationItem data-slot="pagination-item" /> <PaginationLink data-slot="pagination-link" data-active /> <PaginationEllipsis data-slot="pagination-ellipsis" />
Tokens used
10 design tokens consumed by the Pagination component.
--background
Background
--foreground
Foreground
--muted
Muted
--border
Border
--input
Input
--ring
Ring
--radius-md
Radius 0.8× (8px)
--spacing
Spacing unit (4px)
--text-sm
Text Small (14px)
--font-weight-medium
Font weight Medium (500)