Page navigation with a measured pill that slides between numbers, ellipsis collapsing, and hover-nudged chevrons.
npx shadcn@latest add @paragon/pagination"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight, Ellipsis } from "lucide-react";
import { cn } from "@/lib/utils";
type PaginationSlot = number | "ellipsis-start" | "ellipsis-end";
function range(start: number, end: number): number[] {
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}
function getPaginationRange(
page: number,
totalPages: number,
siblingCount: number,
): PaginationSlot[] {
// first + last + current + siblings on both sides + two ellipsis slots
const totalSlots = siblingCount * 2 + 5;
if (totalPages <= totalSlots) return range(1, totalPages);
const leftSibling = Math.max(page - siblingCount, 1);
const rightSibling = Math.min(page + siblingCount, totalPages);
const showLeftEllipsis = leftSibling > 2;
const showRightEllipsis = rightSibling < totalPages - 1;
if (!showLeftEllipsis && showRightEllipsis) {
return [...range(1, 3 + 2 * siblingCount), "ellipsis-end", totalPages];
}
if (showLeftEllipsis && !showRightEllipsis) {
return [
1,
"ellipsis-start",
...range(totalPages - (2 + 2 * siblingCount), totalPages),
];
}
return [
1,
"ellipsis-start",
...range(leftSibling, rightSibling),
"ellipsis-end",
totalPages,
];
}
export interface PaginationProps
extends Omit<React.ComponentProps<"nav">, "onChange"> {
totalPages: number;
page?: number;
defaultPage?: number;
onPageChange?: (page: number) => void;
/** Pages shown on each side of the current page. */
siblingCount?: number;
/** Disables the sliding pill motion; selection snaps instantly. */
static?: boolean;
}
/**
* Page navigation with a single measured pill that slides between page
* numbers (offsetLeft/offsetWidth, transform + width transition — the same
* technique as animated-tabs) and prev/next chevrons that nudge 2px on
* hover, gated to fine pointers. Long ranges collapse into ellipses.
*/
export function Pagination({
totalPages,
page: pageProp,
defaultPage = 1,
onPageChange,
siblingCount = 1,
static: isStatic = false,
className,
...props
}: PaginationProps) {
const [internalPage, setInternalPage] = React.useState(defaultPage);
const page = Math.min(Math.max(pageProp ?? internalPage, 1), totalPages);
const containerRef = React.useRef<HTMLDivElement>(null);
const pillRef = React.useRef<HTMLSpanElement>(null);
const hasPositioned = React.useRef(false);
const setPage = (next: number) => {
const clamped = Math.min(Math.max(next, 1), totalPages);
if (clamped === page) return;
setInternalPage(clamped);
onPageChange?.(clamped);
};
const position = React.useCallback((animate: boolean) => {
const container = containerRef.current;
const pill = pillRef.current;
if (!container || !pill) return;
const active = container.querySelector<HTMLElement>(
'[aria-current="page"]',
);
if (!active) {
pill.style.opacity = "0";
return;
}
if (!animate) pill.style.transitionProperty = "none";
pill.style.opacity = "1";
pill.style.width = `${active.offsetWidth}px`;
pill.style.transform = `translateX(${active.offsetLeft}px)`;
if (!animate) {
void pill.offsetWidth;
pill.style.transitionProperty = "";
}
}, []);
React.useLayoutEffect(() => {
position(hasPositioned.current && !isStatic);
hasPositioned.current = true;
}, [position, page, totalPages, isStatic]);
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(() => position(false));
observer.observe(container);
return () => observer.disconnect();
}, [position]);
const slots = getPaginationRange(page, totalPages, siblingCount);
const navButtonClass =
"group relative inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out after:absolute after:inset-x-0 after:-inset-y-1 hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40";
return (
<nav aria-label="Pagination" className={cn("w-fit", className)} {...props}>
<div ref={containerRef} className="relative flex items-center gap-1">
<span
ref={pillRef}
aria-hidden
className="pointer-events-none absolute top-0 left-0 h-8 rounded-md bg-primary opacity-0 [transition-property:transform,width] duration-[250ms] ease-out motion-reduce:transition-none"
/>
<button
type="button"
aria-label="Previous page"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
className={navButtonClass}
>
<ChevronLeft className="size-4 transition-transform duration-150 ease-out [@media(hover:hover)_and_(pointer:fine)]:group-hover:-translate-x-0.5" />
</button>
{slots.map((slot) =>
typeof slot === "number" ? (
<button
key={slot}
type="button"
aria-current={slot === page ? "page" : undefined}
aria-label={`Page ${slot}`}
onClick={() => setPage(slot)}
className="relative z-10 inline-flex h-8 min-w-8 items-center justify-center rounded-md px-1.5 text-[13px] font-medium text-muted-foreground tabular-nums transition-colors duration-150 ease-out after:absolute after:inset-x-0 after:-inset-y-1 hover:text-foreground aria-[current=page]:text-primary-foreground"
>
{slot}
</button>
) : (
<span
key={slot}
aria-hidden
className="flex size-8 items-center justify-center text-muted-foreground/60"
>
<Ellipsis className="size-3.5" />
</span>
),
)}
<button
type="button"
aria-label="Next page"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
className={navButtonClass}
>
<ChevronRight className="size-4 transition-transform duration-150 ease-out [@media(hover:hover)_and_(pointer:fine)]:group-hover:translate-x-0.5" />
</button>
</div>
</nav>
);
}