Compact prev/next pager whose page number rolls vertically in the direction of travel inside a fixed-width column, with optional edge jumps and polite live announcements.
npx shadcn@latest add @paragon/pagination-mini"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
ChevronFirst,
ChevronLast,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
export interface PaginationMiniProps
extends Omit<React.ComponentProps<"nav">, "onChange"> {
/** Total number of pages. */
count: number;
/** Current page, 1-based (controlled). */
page?: number;
defaultPage?: number;
onPageChange?: (page: number) => void;
/** Add jump-to-first/last buttons. */
showEdges?: boolean;
/** Disables the rolling digit motion. */
static?: boolean;
}
/**
* The pager for tight corners — toolbars, card footers, split panes. Just
* prev/next around a "3 of 12" readout whose page number rolls vertically in
* the direction of travel (next pulls the new number up from below, prev
* drops it in from above). The number column is fixed-width ch so nothing
* reflows, edges disable cleanly, and changes announce through a polite live
* region. Paging is a tens-of-times-daily action: one 300ms spring, nothing
* else moves.
*/
export function PaginationMini({
count,
page: pageProp,
defaultPage = 1,
onPageChange,
showEdges = false,
static: isStatic = false,
className,
...props
}: PaginationMiniProps) {
const reducedMotion = useReducedMotion();
const [internalPage, setInternalPage] = React.useState(defaultPage);
const page = Math.min(Math.max(pageProp ?? internalPage, 1), count);
// Direction derives from the previous render's page (render-phase derived
// state — no effects, no Date.now, fully deterministic).
const [displayed, setDisplayed] = React.useState(page);
const direction = React.useRef(1);
if (page !== displayed) {
direction.current = page > displayed ? 1 : -1;
setDisplayed(page);
}
const go = (next: number) => {
const clamped = Math.min(Math.max(next, 1), count);
if (clamped === page) return;
setInternalPage(clamped);
onPageChange?.(clamped);
};
const digits = String(Math.max(count, 1)).length;
const buttonClasses = cn(
"relative flex size-8 items-center justify-center rounded-md text-muted-foreground outline-none",
"transition-colors duration-150 ease-out",
"after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
"hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
"disabled:pointer-events-none disabled:opacity-40",
!isStatic && "pressable",
);
const variants = {
enter: (dir: number) =>
reducedMotion || isStatic
? { opacity: 0 }
: { y: dir * 10, opacity: 0, filter: "blur(3px)" },
center: { y: 0, opacity: 1, filter: "blur(0px)" },
exit: (dir: number) =>
reducedMotion || isStatic
? { opacity: 0 }
: { y: dir * -10, opacity: 0, filter: "blur(3px)" },
};
return (
<nav
data-slot="pagination-mini"
aria-label="Pagination"
className={cn("inline-flex items-center gap-0.5", className)}
{...props}
>
{showEdges && (
<button
type="button"
aria-label="First page"
disabled={page <= 1}
onClick={() => go(1)}
className={buttonClasses}
>
<ChevronFirst className="size-4" />
</button>
)}
<button
type="button"
aria-label="Previous page"
disabled={page <= 1}
onClick={() => go(page - 1)}
className={buttonClasses}
>
<ChevronLeft className="size-4" />
</button>
<p className="flex items-baseline gap-1 px-1 text-[13px] tabular-nums select-none">
<span
className="inline-flex justify-center overflow-hidden font-medium text-foreground"
style={{ width: `${digits}ch` }}
aria-hidden
>
<AnimatePresence mode="popLayout" initial={false} custom={direction.current}>
<motion.span
key={page}
custom={direction.current}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="inline-block"
>
{page}
</motion.span>
</AnimatePresence>
</span>
<span aria-hidden className="text-muted-foreground">
of {count}
</span>
<span aria-live="polite" className="sr-only">
Page {page} of {count}
</span>
</p>
<button
type="button"
aria-label="Next page"
disabled={page >= count}
onClick={() => go(page + 1)}
className={buttonClasses}
>
<ChevronRight className="size-4" />
</button>
{showEdges && (
<button
type="button"
aria-label="Last page"
disabled={page >= count}
onClick={() => go(count)}
className={buttonClasses}
>
<ChevronLast className="size-4" />
</button>
)}
</nav>
);
}