A page navigator with compact and neighboring ranges, text-only and outline styles, and optional sliding numbers.
Page 1 of 12
pnpm dlx shadcn@latest add @latheui/paginationFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add lucide-reactCopy the source into your project.
"use client"
import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
export type PaginationProps = Omit<React.ComponentProps<"nav">, "onChange"> & {
page: number
pageCount: number
onPageChange: (page: number) => void
variant?: "solid" | "text" | "outline"
density?: "normal" | "compact"
layout?: "compact" | "siblings" | "all" | "simple"
siblingCount?: number
animation?: "none" | "slide"
}
export function Pagination({ page, pageCount, onPageChange, variant = "solid", density = "normal", layout = "siblings", siblingCount = 1, animation = "none", className, ...props }: PaginationProps) {
const count = Math.max(1, Number.isFinite(pageCount) ? Math.floor(pageCount) : 1)
const current = Math.min(count, Math.max(1, Number.isFinite(page) ? Math.floor(page) : 1))
const radius = Math.max(0, Math.min(3, Number.isFinite(siblingCount) ? Math.floor(siblingCount) : 1))
let visible: number[]
if (layout === "simple") visible = []
else if (layout === "all" || count <= 5 || layout === "siblings" && count <= radius * 2 + 5) visible = Array.from({length: count}, (_, i) => i + 1)
else if (layout === "siblings") {
const start = Math.max(2, Math.min(current - radius, count - radius * 2 - 1))
const end = Math.min(count - 1, Math.max(current + radius, 2 + radius * 2))
visible = [1, ...Array.from({length: end - start + 1}, (_, i) => i + start), count]
} else visible = current <= 3 ? [1,2,3,count] : current >= count - 2 ? [1,count-2,count-1,count] : [1,current,count]
const numbers = React.useRef<HTMLDivElement>(null)
const previous = React.useRef(current)
React.useEffect(() => {
const direction = current > previous.current ? 1 : -1
const changed = current !== previous.current
previous.current = current
if (!changed || animation === "none" || matchMedia("(prefers-reduced-motion: reduce)").matches) return
const animations = Array.from(numbers.current?.querySelectorAll("[data-page-number]") ?? []).map(n => n.animate([{opacity: .35, transform: `translateX(${direction * 10}px)`}, {opacity: 1, transform: "none"}], {duration: 240, easing: "cubic-bezier(.22,1,.36,1)"}))
return () => animations.forEach(a => a.cancel())
}, [current, animation])
const size = density === "compact" ? "size-7" : "size-8"
const button = cn("grid shrink-0 cursor-pointer place-items-center rounded-md text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40 transition-colors", size, variant === "text" ? "bg-transparent text-muted-foreground hover:text-foreground" : "hover:bg-accent")
return <nav aria-label="Pagination" data-slot="pagination" data-variant={variant} data-layout={layout} className={cn("flex flex-wrap items-center", density === "compact" ? "gap-0" : "gap-1", className)} {...props}>
<button type="button" aria-label="Previous page" disabled={current === 1} onClick={() => onPageChange(current - 1)} className={cn(button, layout === "simple" && "flex w-auto gap-1 px-2")}><ChevronLeftIcon aria-hidden="true" className="size-4" />{layout === "simple" && "Previous"}</button>
<div ref={numbers} className={cn("flex flex-wrap items-center", density === "compact" ? "gap-0" : "gap-1")}>
{layout === "simple" ? <span data-page-number="" className="px-3 text-xs tabular-nums text-muted-foreground">{current} / {count}</span> : visible.map((n, i) => <React.Fragment key={n}>
{i > 0 && n - visible[i-1] > 1 && <span aria-hidden="true" className={cn("grid shrink-0 place-items-center text-muted-foreground", size)}>…</span>}
<button type="button" aria-label={"Page " + n} aria-current={n === current ? "page" : undefined} onClick={() => onPageChange(n)} className={cn(button, variant === "outline" && "border border-border", n === current && (variant === "solid" ? "bg-primary text-primary-foreground hover:bg-primary/90" : variant === "outline" ? "border-foreground font-semibold" : "font-semibold text-foreground underline underline-offset-4"))}><span data-page-number="" className="inline-block">{n}</span></button>
</React.Fragment>)}
</div>
<button type="button" aria-label="Next page" disabled={current === count} onClick={() => onPageChange(current + 1)} className={cn(button, layout === "simple" && "flex w-auto gap-1 px-2")}>{layout === "simple" && "Next"}<ChevronRightIcon aria-hidden="true" className="size-4" /></button>
</nav>
}Depends on
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "solid" | "text" | "outline" | "solid" | Text uses no background, including on hover. Outline uses bordered buttons. |
| density | "normal" | "compact" | "normal" | Compact uses smaller buttons and removes gaps between numbers. |
| layout | "compact" | "siblings" | "all" | "simple" | "siblings" | Show the first and last pages with the current page and its neighbors (1 … 3 4 5 … 12), keep a fixed five-slot range, show all pages, or use a page counter. |
| siblingCount | number | 1 | Pages shown on each side of the current page in the siblings layout, from 0 to 3. |
| animation | "none" | "slide" | "none" | Slide numbers left when moving forward and right when moving backward. Reduced motion skips the animation. |
| page* (required) | number | — | Current page, starting at 1. |
| pageCount* (required) | number | — | Total page count. |
| onPageChange* (required) | (page: number) => void | — | Called when another page is requested. |
Combine the control with your own layout. Semantic colors follow the active theme.
import * as React from "react"
import { Pagination } from "@/components/ui/pagination"
export default function Demo(){const [page,setPage]=React.useState(1);return <div className="space-y-3"><Pagination page={page} pageCount={12} onPageChange={setPage}/><p role="status" className="text-center text-xs text-muted-foreground">Page {page} of 12</p></div>}