## Pagination — Navigation The window moves, the row does not. Docs: https://www.interior.dev/docs/pagination Reference: https://www.interior.dev/reference/pagination License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/pagination.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useRouter, useSearchParams } from "next/navigation"; import { Pagination } from "@/components/interior/pagination"; export function ResultsPager({ total, perPage }: { total: number; perPage: number }) { const router = useRouter(); const params = useSearchParams(); const page = Math.max(1, Number(params.get("page") ?? 1)); return ( { const query = new URLSearchParams(params); query.set("page", String(next)); router.push(`?${query.toString()}`, { scroll: false }); }} /> ); } ``` ### Props - `count` (`number`) Total number of pages. The slot geometry is derived from its digit count, so the row is sized for its widest page from the first paint. - `page` (`number`) Controlled current page. Supplying it makes the parent the source of truth. - `defaultPage` (`number`) — default: `1` Uncontrolled starting page, clamped into range. - `siblings` (`number`) — default: `1` Pages shown on each side of the current page once the window is sliding. - `boundaries` (`number`) — default: `1` Pages pinned at each end of the row no matter where the window is. - `onPageChange` (`(page: number) => void`) Fires with the next page on every move, controlled or not. - `label` (`string`) — default: `"Pagination"` Accessible name for the nav landmark. - `className` (`string`) — default: `""` Appended last to the nav element, so a caller's spacing wins. ### Behavior notes - The slot count is identical at every page — the near-start, middle and near-end windows all resolve to the same number of cells, so moving from page 4 to page 5 can never change the row's width or shift the arrows under the cursor. - The active marker is one always-mounted thumb translated between slots, the dropdown's answer rather than a shared-layout animation: it cannot fly in from a stale position, and slots need no measurement because their width is arithmetic. - When the window slides, only the cells whose number changed remount, and the new number rolls in from the direction the window travelled; unchanged cells do not flicker. - The arrows at their limits stay in the accessibility tree with aria-disabled and refuse the click in the handler, keeping our colours instead of the UA's disabled grey. - Page changes are announced once through a status region after the page has held for half a second, so paging through quickly is one sentence, not a stutter of interruptions. - Under prefers-reduced-motion the thumb and the numbers arrive instantly; nothing is hidden, only the travel is dropped. ### Source (`components/interior/pagination.tsx`) ```tsx "use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { motion, useReducedMotion } from "motion/react"; const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const; const EASE = [0.23, 1, 0.32, 1] as const; const ROLL = { duration: 0.18, ease: EASE } as const; const STILL = { duration: 0 } as const; const slotFor = (digits: number) => Math.max(32, 18 + digits * 8); const GAP = 4; const range = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, i) => from + i); const arrow = (can: boolean) => `flex h-8 w-8 shrink-0 items-center justify-center rounded-[9px] outline-none transition-colors duration-150 focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF] ${ can ? "text-stone-500 hover:bg-stone-100 hover:text-stone-800 dark:text-stone-400 dark:hover:bg-white/[0.06] dark:hover:text-stone-200" : "text-stone-300 dark:text-white/20" }`; export type PaginationItem = number | "gap-l" | "gap-r"; export function paginate( page: number, count: number, siblings: number, boundaries: number, ): PaginationItem[] { const total = 2 * boundaries + 2 * siblings + 3; if (count <= total) return range(1, count); const nearStart = page < boundaries + siblings + 2; const nearEnd = page > count - boundaries - siblings - 1; if (nearStart) { return [ ...range(1, 2 * siblings + boundaries + 2), "gap-r", ...range(count - boundaries + 1, count), ]; } if (nearEnd) { return [ ...range(1, boundaries), "gap-l", ...range(count - 2 * siblings - boundaries - 1, count), ]; } return [ ...range(1, boundaries), "gap-l", ...range(page - siblings, page + siblings), "gap-r", ...range(count - boundaries + 1, count), ]; } export type UsePaginationOptions = { count: number; page?: number; defaultPage?: number; siblings?: number; boundaries?: number; onPageChange?: (page: number) => void; }; export function usePagination({ count, page, defaultPage = 1, siblings = 1, boundaries = 1, onPageChange, }: UsePaginationOptions) { const clampTo = useCallback( (value: number) => Math.min(Math.max(1, value), Math.max(1, count)), [count], ); const [internal, setInternal] = useState(() => clampTo(defaultPage)); const controlled = page !== undefined; const current = clampTo(controlled ? page : internal); const emit = useRef(onPageChange); emit.current = onPageChange; const previous = useRef(current); const direction = current >= previous.current ? 1 : -1; useEffect(() => { previous.current = current; }, [current]); const goTo = useCallback( (value: number) => { const next = clampTo(value); if (next === previous.current) return; if (!controlled) setInternal(next); emit.current?.(next); }, [clampTo, controlled], ); const items = paginate(current, count, siblings, boundaries); return { page: current, count, items, direction, thumbIndex: items.indexOf(current), canPrev: current > 1, canNext: current < count, goTo, prev: () => goTo(current - 1), next: () => goTo(current + 1), }; } export type PaginationProps = { count: number; page?: number; defaultPage?: number; siblings?: number; boundaries?: number; onPageChange?: (page: number) => void; label?: string; className?: string; }; function Chevron({ flip = false }: { flip?: boolean }) { return ( ); } export function Pagination({ count, page, defaultPage, siblings, boundaries, onPageChange, label = "Pagination", className = "", }: PaginationProps) { const pagination = usePagination({ count, page, defaultPage, siblings, boundaries, onPageChange, }); const { items, direction, thumbIndex, canPrev, canNext } = pagination; const current = pagination.page; const reduced = useReducedMotion(); const digits = String(Math.max(1, count)).length; const slot = slotFor(digits); const [spoken, setSpoken] = useState(""); useEffect(() => { const t = setTimeout( () => setSpoken(`Page ${current} of ${Math.max(1, count)}`), 500, ); return () => clearTimeout(t); }, [current, count]); return ( ); } ```