Components

Pagination

Previous, next and a page window that keeps its width.

Loading...

Installation

npx shadcn@latest add @fujin/pagination

This also installs @fujin/use-pagination (lib/pagination.ts), the headless window logic.

Usage

import { PaginationControls } from "@/components/ui/pagination"
const [page, setPage] = React.useState(1)
 
<PaginationControls page={page} pageCount={24} onPageChange={setPage} />

Pages are 1-based. TanStack Table's pageIndex is 0-based - pass page={pageIndex + 1} and onPageChange={(p) => setPageIndex(p - 1)}.

The page window

getPageWindow decides which numbers to show. Once there are more pages than fit, it always returns 2 * boundaries + 2 * siblings + 3 items, so the control keeps its width while you page through - nothing jumps under the pointer. An ellipsis never hides a single page; that page is shown instead.

import { getPageWindow } from "@/lib/pagination"
 
getPageWindow({ page: 1, pageCount: 20 }) // [1, 2, 3, 4, 5, "ellipsis", 20]
getPageWindow({ page: 10, pageCount: 20 }) // [1, "ellipsis", 9, 10, 11, "ellipsis", 20]
getPageWindow({ page: 20, pageCount: 20 }) // [1, "ellipsis", 16, 17, 18, 19, 20]

Pass getHref to render links instead of buttons, and renderLink for a router link. page comes from the URL.

"use client"
 
<PaginationControls
  page={page}
  pageCount={pageCount}
  getHref={(p) => `?page=${p}`}
  renderLink={(href) => <Link href={href} />}
/>

A Server Component cannot pass functions to PaginationControls. Build the same markup from the parts and getPageWindow, which is server-safe:

Loading...

Your own markup

usePagination holds the page state (controlled or uncontrolled) and the window, with no markup:

import { usePagination } from "@/lib/pagination"
 
const { page, items, canPrev, canNext, prev, next, setPage } = usePagination({
  pageCount: 12,
  defaultPage: 1,
  onPageChange: (page) => fetchPage(page),
})

What's different from shadcn/ui

  • PaginationControls and getPageWindow are new: shadcn ships the parts but no page-window logic.
  • render replaces asChild on PaginationLink.
  • Previous/Next keep their visible text as the accessible name (shadcn's aria-label replaces it). Below sm the text is visually hidden.
  • disabled on a link or button sets aria-disabled instead of removing it from the page, so a focused "Next" button keeps focus at the last page.
  • Every target is at least 36px, above the 24px WCAG 2.5.8 minimum.

API Reference

Prop

Type

PaginationLink uses Base UI's useRender.

On this page