Pagination
Crawlable page links with previous and next buttons, an ellipsis for long ranges and a compact mode for phones.
Preview
Building a table?
Data Table already paginates, URL state included. Use
Pagination for your own lists, feeds and galleries.
Usage
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@vitnode/core/components/ui/pagination'<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>
<PaginationEllipsis />
</PaginationItem>
<PaginationItem>
<PaginationNext href="?page=3" />
</PaginationItem>
</PaginationContent>
</Pagination>Every link is a real <a>, so pages stay crawlable and middle-click opens a new
tab. Below sm, previous and next shrink to a chevron that flips in RTL.
With router links
Keep the href for crawlers, and let TanStack Router handle the click:
const navigate = useNavigate()
const goTo = (page: number) => (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault()
void navigate({ to: '.', search: (prev) => ({ ...prev, page }) })
}
;<PaginationLink
href={`?page=${page}`}
isActive={page === current}
onClick={goTo(page)}
>
{page}
</PaginationLink>Ellipsis
Show the first page, the last page and the current one. PaginationEllipsis
covers the rest. This helper keeps five slots, so the row never jumps:
type PageSlot = 'ellipsis-end' | 'ellipsis-start' | number
const range = (from: number, to: number) =>
Array.from({ length: to - from + 1 }, (_, step) => from + step)
const pageWindow = (current: number, total: number): PageSlot[] => {
if (total <= 5) return range(1, total)
if (current <= 3) return [1, 2, 3, 'ellipsis-end', total]
if (current >= total - 2) {
return [1, 'ellipsis-start', ...range(total - 2, total)]
}
return [1, 'ellipsis-start', current, 'ellipsis-end', total]
}{
pageWindow(page, total).map((slot) => (
<PaginationItem key={slot}>
{typeof slot === 'number' ? (
<PaginationLink href={`?page=${slot}`} isActive={slot === page}>
{slot}
</PaginationLink>
) : (
<PaginationEllipsis />
)}
</PaginationItem>
))
}Small screens
Shrink the preview to phone width: the numbers give way to "Page 2 of 12". Add
hidden sm:block to each number's item, plus one item for the phone line:
<PaginationItem className="sm:hidden">
<span className="text-muted-foreground px-2 text-sm">
Page {page} of {total}
</span>
</PaginationItem>Disabled ends
There is no page zero (we checked). Drop the href so the keyboard skips the
link, and add role="link" with aria-disabled so screen readers hear it as
unavailable:
<PaginationPrevious
{...(page > 1
? { href: `?page=${page - 1}` }
: {
'aria-disabled': true,
className: 'pointer-events-none',
role: 'link',
})}
/>Custom labels
text replaces the translated "Previous" / "Next". Pass a matching
aria-label too, or screen readers still announce "Previous page".
<PaginationNext aria-label="Older posts" href="?page=3" text="Older posts" />Accessibility
Paginationis anavlandmark with a translated "Pagination" label.- The active link gets
aria-current="page". - Previous and next keep their translated label when only the chevron shows.
PaginationEllipsishides its icon and reads "More pages" to screen readers.- Announce page changes that don't reload, for example with an
aria-live="polite"line like the preview's "1-3 of 36".
Props
PaginationLink
Prop
Type
PaginationPrevious / PaginationNext
Every PaginationLink prop, plus:
Prop
Type