Pager
Page navigation with prev and next pills, numbered pages and an ellipsis for long ranges.
npx shadcn@latest add https://ui.goodtabs.co/r/pager.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/pager
Page 3 of 12. One sibling on each side, first and last always shown.
Many pages
Page 48 of 120. An ellipsis on both sides of the current page.
Compact
Prev and next around a mono "Page 3 of 12" for tight toolbars.
Usage
import { Pager } from "@/components/ui/pager"
const [page, setPage] = useState(1)
<Pager page={page} pageCount={12} onPageChange={setPage} />
<Pager variant="compact" page={page} pageCount={12} onPageChange={setPage} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| page | number | The current page, from 1. Clamped to the page count. | |
| pageCount | number | Total number of pages. | |
| onPageChange | (page: number) => void | Called with the page to go to. | |
| siblingCount | number | 1 | Pages shown on each side of the current one. |
| showEdges | boolean | true | Always show the first and last page numbers. |
| variant | "numbers" | "compact" | "numbers" | compact drops the numbers for a mono "Page n of m" between the arrows. |
Source
pager/pager.tsx
"use client";
import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type PagerProps = Omit<React.ComponentProps<"nav">, "children"> & {
/** The current page, from 1. */
page: number;
/** Total number of pages. */
pageCount: number;
/** Called with the page to go to. */
onPageChange: (page: number) => void;
/** Pages shown on each side of the current one. */
siblingCount?: number;
/** Always show the first and last page numbers. */
showEdges?: boolean;
/** numbers shows the page pills; compact shows only prev, next and "Page n of m". */
variant?: "numbers" | "compact";
};
type Slot = number | "start-ellipsis" | "end-ellipsis";
function range(start: number, end: number) {
return Array.from({ length: Math.max(0, end - start + 1) }, (_, i) => start + i);
}
/** First, last, the current page with its siblings, and an ellipsis for each gap. */
function getSlots(page: number, pageCount: number, siblingCount: number, showEdges: boolean): Slot[] {
const boundary = showEdges ? 1 : 0;
const total = boundary * 2 + siblingCount * 2 + 3;
if (pageCount <= total) return range(1, pageCount);
const startPages = range(1, boundary);
const endPages = range(pageCount - boundary + 1, pageCount);
const siblingsStart = Math.max(
Math.min(page - siblingCount, pageCount - boundary - siblingCount * 2 - 1),
boundary + 2,
);
const siblingsEnd = Math.min(
Math.max(page + siblingCount, boundary + siblingCount * 2 + 2),
pageCount - boundary - 1,
);
return [
...startPages,
...(siblingsStart > boundary + 2
? (["start-ellipsis"] as Slot[])
: boundary + 1 < pageCount - boundary
? [boundary + 1]
: []),
...range(siblingsStart, siblingsEnd),
...(siblingsEnd < pageCount - boundary - 1
? (["end-ellipsis"] as Slot[])
: pageCount - boundary > boundary
? [pageCount - boundary]
: []),
...endPages,
];
}
/**
* Page navigation: prev and next as outline pills, page numbers as ghost pills,
* the current one filled. Controlled: pass page and handle onPageChange.
*/
export function Pager({
page,
pageCount,
onPageChange,
siblingCount = 1,
showEdges = true,
variant = "numbers",
className,
...props
}: PagerProps) {
const count = Math.max(1, pageCount);
const current = Math.min(Math.max(1, page), count);
const go = (next: number) => {
if (next !== current && next >= 1 && next <= count) onPageChange(next);
};
return (
<nav
data-slot="pager"
aria-label="Pagination"
className={cn("flex items-center gap-1", variant === "compact" && "gap-2", className)}
{...props}
>
<Button
type="button"
variant="outline"
size="icon-sm"
className="rounded-full"
aria-label="Previous page"
disabled={current <= 1}
onClick={() => go(current - 1)}
>
<ChevronLeftIcon aria-hidden="true" />
</Button>
{variant === "compact" ? (
<span className="px-1 font-mono text-xs text-muted-foreground tabular-nums" aria-live="polite">
Page {current} of {count}
</span>
) : (
<>
<ul className="m-0 flex list-none items-center gap-1 p-0">
{getSlots(current, count, Math.max(0, siblingCount), showEdges).map((slot) =>
typeof slot === "number" ? (
<li key={slot}>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Page ${slot}`}
aria-current={slot === current ? "page" : undefined}
onClick={() => go(slot)}
className={cn(
"w-auto min-w-7 rounded-full px-1.5 font-mono text-xs tabular-nums",
slot === current &&
"bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground",
)}
>
{slot}
</Button>
</li>
) : (
<li key={slot} className="flex size-7 items-center justify-center text-muted-foreground">
<MoreHorizontalIcon className="size-4" aria-hidden="true" />
<span className="sr-only">More pages</span>
</li>
),
)}
</ul>
<span className="sr-only" aria-live="polite">
Page {current} of {count}
</span>
</>
)}
<Button
type="button"
variant="outline"
size="icon-sm"
className="rounded-full"
aria-label="Next page"
disabled={current >= count}
onClick={() => go(current + 1)}
>
<ChevronRightIcon aria-hidden="true" />
</Button>
</nav>
);
}
For AI agents
Hand your agent this page, the markdown at /components/pager/prompt.md, or the Copy AI prompt button above.
When to use
- Under a table or list that is split into pages on the server
- When people need to jump to a specific page, not just the next one
- In a toolbar where only prev and next fit: use the compact variant
When not to use
- For feeds that load more as you scroll: use a load more button instead
- For steps in a flow, which have an order and a finish
- When there is only one page: render nothing
Agent notes
- Controlled only: it never stores the page, so pass page and update it in onPageChange
- It takes a function, so the parent that renders it must be a client component
- The current page is bg-primary with aria-current; prev and next disable at the edges
- The row does not wrap; with many pages and a wide siblingCount, give it a scrolling box