Pagination
pnpm dlx shadcn@latest add …/r/pagination.jsonAn accessible React pagination for Next.js and Tailwind CSS: page links with ellipses, Previous and Next, and a pill that slides to the current page.
import {
getPaginationRange,
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
// Pages as URLs (?page=3): links work in a server component
export function Pages({ page, pageCount }: { page: number; pageCount: number }) {
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href={`?page=${page - 1}`} disabled={page === 1} />
</PaginationItem>
{getPaginationRange({ page, pageCount }).map((item, index) => (
<PaginationItem key={item === "ellipsis" ? `ellipsis-${index}` : item}>
{item === "ellipsis" ? (
<PaginationEllipsis />
) : (
<PaginationLink href={`?page=${item}`} isActive={item === page}>
{item}
</PaginationLink>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext href={`?page=${page + 1}`} disabled={page === pageCount} />
</PaginationItem>
</PaginationContent>
</Pagination>
)
}"use client";
import {
getPaginationRange,
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { useState } from "react";
const pageCount = 10;
/*
* Ten pages to click through. The pill slides to each new page; Previous and Next turn off at the ends.
* Buttons, because the page changes in place; give the links an href (or asChild with your router's Link) to
* move between URLs instead.
*/
export default function PaginationPages() {
const [page, setPage] = useState(4);
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious disabled={page === 1} onClick={() => setPage((current) => current - 1)} />
</PaginationItem>
{getPaginationRange({ page, pageCount }).map((item, index) => (
<PaginationItem key={item === "ellipsis" ? `ellipsis-${index}` : item}>
{item === "ellipsis" ? (
<PaginationEllipsis />
) : (
<PaginationLink
isActive={item === page}
aria-label={`Page ${item}`}
onClick={() => setPage(item)}
>
{item}
</PaginationLink>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext disabled={page === pageCount} onClick={() => setPage((current) => current + 1)} />
</PaginationItem>
</PaginationContent>
</Pagination>
);
}Installation
Install the dependencies.
$ pnpm add lucide-react motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Add Button first.
Copy the source into your project.
import { type ButtonProps, buttonVariants } from "@/components/ui/button"; import { PaginationGroup, PaginationPill } from "@/components/ui/pagination-pill"; import { cn } from "@/lib/utils"; import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"; import { Slot } from "radix-ui"; import type { ComponentProps, ReactNode } from "react"; /* * Pagination (owner's roadmap, 2026-10-16: "Move through pages of results"), with shadcn's parts: Pagination, * PaginationContent, PaginationItem, PaginationLink, PaginationPrevious, PaginationNext and PaginationEllipsis. * - A nav landmark ("Pagination") holding a list. The current page has aria-current="page". * - Links are ghost Buttons (buttonVariants), muted until hovered. A pill in Button's outline look marks the * current page and slides to the new one when the page changes (pagination-pill.tsx, the one client part). * - PaginationLink is a link with `href`, a button without one (for pages changed in place, such as a table's), * or with `asChild` your router's Link. At the first and last pages, Previous and Next are `disabled`: * aria-disabled, faded and inert. A button keeps its focus (so pressing Next onto the last page doesn't drop * the keyboard to the top of the page); a link loses its href. * - getPaginationRange works out which pages to show, with ellipses for the gaps, always the same number of * items, so the links don't jump about as you move through them. */ /** A page number, or a gap of pages not shown */ export type PaginationRangeItem = number | "ellipsis"; /** * The pages to show around `page` (1-based) of `pageCount`: the first and last `boundaryCount` pages, * `siblingCount` pages either side of the current one, and "ellipsis" for each gap. When there are enough pages, * the list is always the same length (7 with the defaults), so the links keep their places. */ function getPaginationRange({ page, pageCount, siblingCount = 1, boundaryCount = 1, }: { page: number; pageCount: number; siblingCount?: number; boundaryCount?: number; }): PaginationRangeItem[] { const range = (from: number, to: number) => Array.from({ length: Math.max(0, to - from + 1) }, (_, index) => from + index); if (pageCount <= boundaryCount * 2 + siblingCount * 2 + 3) return range(1, pageCount); const current = Math.min(Math.max(page, 1), pageCount); const start = range(1, boundaryCount); const end = range(pageCount - boundaryCount + 1, pageCount); // The siblings slide along between the ends, keeping the same count as they near either end const siblingsFrom = Math.max( Math.min(current - siblingCount, pageCount - boundaryCount - siblingCount * 2 - 1), boundaryCount + 2, ); const siblingsTo = Math.min( Math.max(current + siblingCount, boundaryCount + siblingCount * 2 + 2), pageCount - boundaryCount - 1, ); // A gap of one page shows that page instead of an ellipsis return [ ...start, ...(siblingsFrom > boundaryCount + 2 ? (["ellipsis"] as const) : [boundaryCount + 1]), ...range(siblingsFrom, siblingsTo), ...(siblingsTo < pageCount - boundaryCount - 1 ? (["ellipsis"] as const) : [pageCount - boundaryCount]), ...end, ]; } function Pagination({ className, "aria-label": label = "Pagination", ...props }: ComponentProps<"nav">) { return ( <nav aria-label={label} data-slot="pagination" className={cn("mx-auto flex w-full justify-center", className)} {...props} /> ); } function PaginationContent({ className, children, ...props }: ComponentProps<"ul">) { return ( <ul data-slot="pagination-content" // The list is the pill's layer: it slides behind every link className={cn("relative isolate flex flex-row items-center gap-1", className)} {...props} > <PaginationGroup>{children}</PaginationGroup> </ul> ); } function PaginationItem({ className, ...props }: ComponentProps<"li">) { return <li data-slot="pagination-item" className={cn("relative", className)} {...props} />; } export type PaginationLinkProps = ComponentProps<"a"> & Pick<ButtonProps, "size"> & { /** The current page: it gets the pill and aria-current="page" */ isActive?: boolean; /** Render the single child (your router's Link) with the link's styles */ asChild?: boolean; /** * Can't be followed (Previous on the first page). It's marked aria-disabled and its onClick isn't called; a * link also loses its href. A button keeps its focus, so the keyboard isn't thrown out at the last page. */ disabled?: boolean; }; function PaginationLink({ className, isActive = false, size = "icon", asChild = false, disabled = false, href, onClick, children, ...props }: PaginationLinkProps) { const shared = { "data-slot": "pagination-link", "data-active": isActive || undefined, "aria-current": isActive ? ("page" as const) : undefined, "aria-disabled": disabled || undefined, // Left out rather than wrapped, so nothing new is created here and links still render on the server onClick: disabled ? undefined : onClick, className: cn( buttonVariants({ variant: "ghost", size }), "text-muted-foreground hover:text-foreground data-active:text-foreground", "aria-disabled:pointer-events-none aria-disabled:opacity-(--opacity-disabled)", className, ), }; let control: ReactNode; if (asChild) { // The href, if any, goes onto your Link along with the styles control = ( <Slot.Root {...shared} {...(href === undefined ? null : { href })} {...props}> {children} </Slot.Root> ); } else if (href === undefined) { // No href: the page changes in place, so it's a button control = ( <button type="button" {...(shared as ComponentProps<"button">)} {...(props as ComponentProps<"button">)} > {children} </button> ); } else { control = ( <a {...shared} {...(disabled ? { role: "link" } : { href })} {...props}> {children} </a> ); } return ( <> {isActive ? ( <PaginationPill className={size === "sm" || size === "icon-sm" ? "rounded-md" : undefined} /> ) : null} {control} </> ); } /** Previous and Next: a chevron, and a label from the sm breakpoint. With asChild, the label goes inside your Link. */ function edgeContent(asChild: boolean, children: ReactNode, parts: ReactNode[]) { return asChild ? [<Slot.Slottable key="link">{children}</Slot.Slottable>, ...parts] : parts; } function PaginationPrevious({ className, asChild = false, children, ...props }: PaginationLinkProps) { return ( <PaginationLink aria-label="Go to previous page" size="md" asChild={asChild} className={cn("gap-1 px-2.5", className)} {...props} > {edgeContent(asChild, children, [ <ChevronLeftIcon key="icon" aria-hidden className="rtl:rotate-180" />, <span key="label" className="hidden sm:block"> Previous </span>, ])} </PaginationLink> ); } function PaginationNext({ className, asChild = false, children, ...props }: PaginationLinkProps) { return ( <PaginationLink aria-label="Go to next page" size="md" asChild={asChild} className={cn("gap-1 px-2.5", className)} {...props} > {edgeContent(asChild, children, [ <span key="label" className="hidden sm:block"> Next </span>, <ChevronRightIcon key="icon" aria-hidden className="rtl:rotate-180" />, ])} </PaginationLink> ); } /** Stands for pages that aren't shown. Screen readers hear "More pages". */ function PaginationEllipsis({ className, ...props }: ComponentProps<"span">) { return ( <span data-slot="pagination-ellipsis" className={cn( "flex size-(--size-control-md) items-center justify-center text-muted-foreground", className, )} {...props} > <MoreHorizontalIcon aria-hidden className="size-(--size-icon-md)" /> <span className="sr-only">More pages</span> </span> ); } export { getPaginationRange, Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, };"use client"; import { spring } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { LayoutGroup, motion, useReducedMotion } from "motion/react"; import { type ReactNode, useId, useLayoutEffect, useRef, useState } from "react"; /* * The client parts of Pagination, so the rest stays a server component (links rendered on the server work). * - One pill marks the current page and slides to the new one when the page changes (layoutId on * spring.snappy, .agents/rules/motion.md), scoped to each Pagination, so two never trade a pill. * - It lies behind the link, inside the list item, rather than inside the link: the link keeps Button's own * hover tint, which is clipped to it, and the pill isn't clipped while it slides. * - It starts where it is on first paint. With reduced motion it jumps. */ /** One pill per Pagination */ function PaginationGroup({ children }: { children: ReactNode }) { const id = useId(); return <LayoutGroup id={id}>{children}</LayoutGroup>; } /** The pill behind the current page's link */ function PaginationPill({ className }: { className?: string }) { const reduce = useReducedMotion() ?? false; const node = useRef<HTMLSpanElement>(null); const [radius, setRadius] = useState<number>(); // Its corners follow the link's own (read from it), as a number, so motion keeps them true if the pill changes // width between pages. The class below draws them until then, including on the server. useLayoutEffect(() => { const link = node.current?.parentElement?.querySelector("[data-slot=pagination-link]"); if (link) setRadius(Number.parseFloat(getComputedStyle(link).borderTopLeftRadius) || 0); }, []); return ( <motion.span ref={node} layoutId="pagination-pill" aria-hidden data-slot="pagination-pill" className={cn( "pointer-events-none absolute inset-0 -z-10 rounded-lg border border-input bg-background", className, )} style={radius === undefined ? undefined : { borderRadius: radius }} transition={reduce ? { duration: 0 } : spring.snappy} /> ); } export { PaginationGroup, PaginationPill };Update the import paths to match your project.
API reference
Pagination
The nav landmark. Also accepts every native nav prop.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label? | string | "Pagination" | The landmark's name. Give each one its own name when a page has two. |
PaginationContent
The list of items (ul). The pill slides within it.
| Prop | Type | Default | Description |
|---|
PaginationItem
One item (li), holding a link or an ellipsis.
| Prop | Type | Default | Description |
|---|
PaginationLink
A page. A link with href, a button without one, or your router's Link with asChild. Styled with buttonVariants (ghost); also accepts every native a prop.
| Prop | Type | Default | Description |
|---|---|---|---|
isActive? | boolean | false | The current page: it gets the pill and aria-current="page". |
href? | string | – | The page's URL. Leave it out for a button that changes the page in place (use onClick). |
asChild? | boolean | false | Render the single child, for example Next.js Link, with the link's styles. When it's disabled, leave out the child's href too. |
disabled? | boolean | false | Marks it aria-disabled, fades it and stops it: onClick isn't called and a link loses its href. A button keeps its focus. |
size? | "sm" | "md" | "lg" | "icon" | "icon-sm" | "icon-lg" | "icon" | Button's sizes. Page numbers are square icon sizes. |
PaginationPrevious
A link to the previous page: a chevron, with the word Previous from the sm breakpoint. Named "Go to previous page". Takes PaginationLink's props.
| Prop | Type | Default | Description |
|---|
PaginationNext
A link to the next page, named "Go to next page". Takes PaginationLink's props.
| Prop | Type | Default | Description |
|---|
PaginationEllipsis
Stands for pages that aren't shown. Screen readers hear "More pages".
| Prop | Type | Default | Description |
|---|
getPaginationRange
Works out which pages to show: returns page numbers and "ellipsis" for each gap. With enough pages the list is always the same length, so the links keep their places.
| Prop | Type | Default | Description |
|---|---|---|---|
page | number | – | The current page, from 1. |
pageCount | number | – | How many pages there are. |
siblingCount? | number | 1 | Pages shown either side of the current one. |
boundaryCount? | number | 1 | Pages always shown at the start and the end. |
Related components
Invoice Status Amount INV-1048 Paid $1,250.00 INV-1047 Pending $480.00 INV-1046 Overdue $2,900.00 An accessible React table for Next.js and Tailwind CSS: rows and columns with sortable headers, selectable rows and rows that glide when sorted.
An accessible React breadcrumb for Next.js and Tailwind CSS: a trail of links whose levels animate as you move, with custom separators and a collapsed middle.
An accessible React segmented control for Next.js: one choice from a few options, with a pill that slides to the one you pick.