"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
type Spring = { stiffness: number; damping: number; mass: number };
export type PaginationToken = number | "start-ellipsis" | "end-ellipsis";
export interface PaginationRangeOptions {
/** Current page, 1-based. */
page: number;
totalPages: number;
/** Pages shown on each side of the current page. */
siblingCount?: number;
/** Pages always shown at the start and end. */
boundaryCount?: number;
}
const range = (start: number, end: number) => (end < start ? [] : Array.from({ length: end - start + 1 }, (_, i) => start + i));
/**
* Pure helper: the page numbers and ellipses to render. The output length is constant for a given
* total, sibling and boundary count, so slots never jump around as the current page moves.
* An ellipsis that would hide a single page is replaced by that page.
*/
export function getPaginationRange({ page, totalPages, siblingCount = 1, boundaryCount = 1 }: PaginationRangeOptions): PaginationToken[] {
const total = Math.max(0, Math.floor(totalPages));
if (total === 0) return [];
const current = Math.min(Math.max(1, Math.floor(page)), total);
const siblings = Math.max(0, Math.floor(siblingCount));
const boundary = Math.max(0, Math.floor(boundaryCount));
const startPages = range(1, Math.min(boundary, total));
const endPages = range(Math.max(total - boundary + 1, boundary + 1), total);
const siblingsStart = Math.max(
Math.min(current - siblings, total - boundary - siblings * 2 - 1),
boundary + 2,
);
const siblingsEnd = Math.min(
Math.max(current + siblings, boundary + siblings * 2 + 2),
endPages.length > 0 ? endPages[0] - 2 : total - 1,
);
return [
...startPages,
...(siblingsStart > boundary + 2 ? (["start-ellipsis"] as const) : boundary + 1 < total - boundary ? [boundary + 1] : []),
...range(siblingsStart, siblingsEnd),
...(siblingsEnd < total - boundary - 1
? (["end-ellipsis"] as const)
: total - boundary > boundary
? [total - boundary]
: []),
...endPages,
];
}
export interface FluidPaginationProps extends Omit<React.HTMLAttributes<HTMLElement>, "onChange"> {
/** Current page, 1-based (controlled). */
page?: number;
/** Initial page (uncontrolled). */
defaultPage?: number;
/** Called with the requested page. */
onPageChange?: (page: number) => void;
/** Number of pages. */
totalPages: number;
/** Pages shown on each side of the current page. */
siblingCount?: number;
/** Pages always shown at the start and end. */
boundaryCount?: number;
/** "full" page list, "compact" (Page 3 of 12 with arrows), or "responsive" (compact in containers narrower than 28rem). */
variant?: "full" | "compact" | "responsive";
/** Show previous / next buttons in the full variant. */
showPrevNext?: boolean;
/** Spring for the sliding active indicator. */
spring?: Spring;
/** Accent for the active page and focus rings. */
accent?: string;
/** Accessible name of the nav landmark. */
label?: string;
previousLabel?: string;
nextLabel?: string;
}
const BTN =
"relative inline-flex h-9 min-w-9 items-center justify-center rounded-[10px] text-sm font-medium tabular-nums tracking-tight transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--fp-accent)] disabled:cursor-not-allowed disabled:opacity-35";
const IDLE = "text-zinc-600 hover:bg-zinc-900/[0.05] hover:text-zinc-950 dark:text-zinc-400 dark:hover:bg-white/[0.06] dark:hover:text-white";
function Chevron({ dir }: { dir: -1 | 1 }) {
return (
<svg viewBox="0 0 16 16" className="h-4 w-4" aria-hidden="true" fill="none">
<path d={dir < 0 ? "M10 3.5 5.5 8l4.5 4.5" : "M6 3.5 10.5 8 6 12.5"} stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** A number that rolls up or down when it changes. */
function Roll({ value, direction, reduceMotion }: { value: React.ReactNode; direction: number; reduceMotion: boolean }) {
return (
<span className="relative inline-flex overflow-hidden">
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
<motion.span
key={String(value)}
custom={direction}
variants={{
enter: (d: number) => ({ y: reduceMotion ? 0 : `${d * 100}%`, opacity: reduceMotion ? 1 : 0 }),
center: { y: "0%", opacity: 1 },
exit: (d: number) => ({ y: reduceMotion ? 0 : `${d * -100}%`, opacity: 0 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={reduceMotion ? { duration: 0 } : { duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
className="inline-block"
>
{value}
</motion.span>
</AnimatePresence>
</span>
);
}
/**
* Pagination with a sliding active indicator, windowed ellipses and numbers that roll in the
* direction of travel. Renders a labelled nav landmark with aria-current="page" on the current page.
*/
export function FluidPagination({
page,
defaultPage = 1,
onPageChange,
totalPages,
siblingCount = 1,
boundaryCount = 1,
variant = "full",
showPrevNext = true,
spring = { stiffness: 420, damping: 34, mass: 0.8 },
accent = "#FF7448",
label = "Pagination",
previousLabel = "Previous page",
nextLabel = "Next page",
className = "",
style,
...props
}: FluidPaginationProps) {
const uid = React.useId();
const reduceMotion = usePrefersReducedMotion();
const total = Math.max(1, Math.floor(totalPages));
const [internal, setInternal] = React.useState(defaultPage);
const current = Math.min(Math.max(1, page ?? internal), total);
// Direction of the last move (+1 forward, -1 back), derived when the page changes.
const [prev, setPrev] = React.useState(current);
const [direction, setDirection] = React.useState(1);
if (prev !== current) {
setPrev(current);
setDirection(current > prev ? 1 : -1);
}
const go = (next: number) => {
const clamped = Math.min(Math.max(1, next), total);
if (clamped === current) return;
if (page === undefined) setInternal(clamped);
onPageChange?.(clamped);
};
const tokens = getPaginationRange({ page: current, totalPages: total, siblingCount, boundaryCount });
const transition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
const prevButton = (extra = "") => (
<button type="button" aria-label={previousLabel} disabled={current <= 1} onClick={() => go(current - 1)} className={`${BTN} ${IDLE} ${extra}`}>
<Chevron dir={-1} />
</button>
);
const nextButton = (extra = "") => (
<button type="button" aria-label={nextLabel} disabled={current >= total} onClick={() => go(current + 1)} className={`${BTN} ${IDLE} ${extra}`}>
<Chevron dir={1} />
</button>
);
const full = (
<ul className="flex flex-wrap items-center justify-center gap-1">
{showPrevNext && <li>{prevButton()}</li>}
{tokens.map((token, slot) => {
// Slots are keyed by position, so when the window shifts the numbers roll in place.
if (typeof token !== "number") {
return (
<li key={`slot-${slot}`} aria-hidden="true" className="inline-flex h-9 min-w-9 items-center justify-center font-mono text-xs tracking-[0.2em] text-zinc-400 dark:text-zinc-500">
…
</li>
);
}
const active = token === current;
return (
<li key={`slot-${slot}`}>
<button
type="button"
aria-label={`Page ${token}`}
aria-current={active ? "page" : undefined}
onClick={() => go(token)}
className={`${BTN} px-2 ${active ? "text-zinc-950" : IDLE}`}
>
{active && (
<motion.span
aria-hidden="true"
layoutId={`${uid}-indicator`}
transition={transition}
className="absolute inset-0 rounded-[10px] bg-[var(--fp-accent)] shadow-[0_1px_2px_rgba(0,0,0,0.12)]"
/>
)}
<span className="relative">
<Roll value={token} direction={direction} reduceMotion={reduceMotion} />
</span>
</button>
</li>
);
})}
{showPrevNext && <li>{nextButton()}</li>}
</ul>
);
const compact = (
<div className="flex items-center gap-2">
{prevButton("border border-zinc-900/10 dark:border-white/10")}
<p className="flex min-w-[8.5rem] items-baseline justify-center gap-1.5 text-sm text-zinc-500 dark:text-zinc-400">
<span aria-live="polite" aria-atomic="true" className="sr-only">
{`Page ${current} of ${total}`}
</span>
<span aria-hidden="true" className="font-mono text-[10px] uppercase tracking-[0.14em]">Page</span>
<span aria-hidden="true" className="font-semibold tabular-nums text-zinc-950 dark:text-zinc-50">
<Roll value={current} direction={direction} reduceMotion={reduceMotion} />
</span>
<span aria-hidden="true" className="font-mono text-[10px] uppercase tracking-[0.14em]">of</span>
<span aria-hidden="true" className="font-medium tabular-nums text-zinc-700 dark:text-zinc-300">{total}</span>
</p>
{nextButton("border border-zinc-900/10 dark:border-white/10")}
</div>
);
return (
<nav
aria-label={label}
className={`${variant === "responsive" ? "@container w-full" : ""} ${className}`}
style={{ ["--fp-accent" as string]: accent, ...style }}
{...props}
>
{variant === "full" && full}
{variant === "compact" && compact}
{variant === "responsive" && (
<>
{/* Only one of these is displayed; display:none removes the other from the accessibility tree. */}
<div className="hidden justify-center @md:flex">{full}</div>
<div className="flex justify-center @md:hidden">{compact}</div>
</>
)}
</nav>
);
}