Page navigation with smart ellipsis truncation (boundary and sibling counts), Previous/Next buttons and an active-page indicator that springs from page to page. Works controlled or uncontrolled.
Preview
Customize
11 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add paginationWrites components/ui/pagination.tsx and components/styles/pagination.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { Pagination } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Pagination />6 );7}Props
Accessibility
Rendered as <nav aria-label> with a list of native buttons, so it is fully operable with Tab, Enter and Space. Each page button is labelled 'Page N' and the current page carries aria-current="page"; Previous/Next have explicit labels and are disabled at the ends. Ellipses are aria-hidden. A visually hidden polite live region announces 'Page X of Y' after each change. Under prefers-reduced-motion the indicator moves instantly.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { motion, useReducedMotion } from "motion/react";5import styles from "../styles/pagination.module.css";6 7export interface PaginationProps {8 /** Controlled current page (1-based) */9 page?: number;10 /** Initial page when uncontrolled */11 defaultPage?: number;12 /** Called with the next page */13 onPageChange?: (page: number) => void;14 /** Total number of pages */15 totalPages?: number;16 /** Pages shown on each side of the current page */17 siblingCount?: number;18 /** Pages always shown at the start and end */19 boundaryCount?: number;20 /** Visual style of the active indicator */21 variant?: "solid" | "outline" | "soft";22 /** Button shape */23 shape?: "rounded" | "circle";24 /** Size preset */25 size?: "sm" | "md" | "lg";26 /** Accent color of the active indicator */27 accentColor?: string;28 /** Show Previous / Next buttons */29 showPrevNext?: boolean;30 /** Show text labels on the Previous / Next buttons */31 showLabels?: boolean;32 /** Disable the whole control */33 disabled?: boolean;34 /** Accessible label of the nav landmark */35 ariaLabel?: string;36 /** Optional className merged onto the root */37 className?: string;38 /** Optional inline styles merged onto the root */39 style?: React.CSSProperties;40}41 42type Slot = number | "start-ellipsis" | "end-ellipsis";43const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;44 45function range(a: number, b: number): number[] {46 const out: number[] = [];47 for (let i = a; i <= b; i++) out.push(i);48 return out;49}50 51/** Builds the visible page slots with ellipses, keeping the slot count stable while paging. */52function buildSlots(page: number, total: number, sib: number, bnd: number): Slot[] {53 const start = range(1, Math.min(bnd, total));54 const end = range(Math.max(total - bnd + 1, bnd + 1), total);55 const sibStart = Math.max(Math.min(page - sib, total - bnd - sib * 2 - 1), bnd + 2);56 const sibEnd = Math.min(Math.max(page + sib, bnd + sib * 2 + 2), end.length > 0 ? end[0] - 2 : total - 1);57 const out: Slot[] = [...start];58 if (sibStart > bnd + 2) out.push("start-ellipsis");59 else if (bnd + 1 < total - bnd) out.push(bnd + 1);60 out.push(...range(sibStart, sibEnd));61 if (sibEnd < total - bnd - 1) out.push("end-ellipsis");62 else if (total - bnd > bnd) out.push(total - bnd);63 out.push(...end);64 return out.filter((s, i, arr) => arr.indexOf(s) === i && (typeof s !== "number" || (s >= 1 && s <= total)));65}66 67function Chevron({ dir }: { dir: "left" | "right" }) {68 return (69 <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">70 <path d={dir === "left" ? "m15 6-6 6 6 6" : "m9 6 6 6-6 6"} />71 </svg>72 );73}74 75export function Pagination({76 page: pageProp,77 defaultPage = 4,78 onPageChange,79 totalPages = 12,80 siblingCount = 1,81 boundaryCount = 1,82 variant = "solid",83 shape = "rounded",84 size = "md",85 accentColor = "#6366f1",86 showPrevNext = true,87 showLabels = true,88 disabled = false,89 ariaLabel = "Pagination",90 className = "",91 style,92}: PaginationProps) {93 const reduce = useReducedMotion();94 const total = Math.max(1, Math.round(totalPages));95 const [inner, setInner] = React.useState(defaultPage);96 const controlled = pageProp !== undefined;97 const page = Math.min(total, Math.max(1, Math.round(controlled ? pageProp : inner)));98 const listRef = React.useRef<HTMLUListElement>(null);99 const btnRefs = React.useRef(new Map<number, HTMLButtonElement>());100 const [rect, setRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(null);101 const slots = buildSlots(page, total, Math.max(0, Math.round(siblingCount)), Math.max(1, Math.round(boundaryCount)));102 103 const go = (p: number) => {104 const next = Math.min(total, Math.max(1, p));105 if (next === page || disabled) return;106 if (!controlled) setInner(next);107 onPageChange?.(next);108 };109 110 const measure = React.useCallback(() => {111 const el = btnRefs.current.get(page);112 if (el) setRect({ x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight });113 }, [page]);114 115 useIsoLayoutEffect(() => {116 measure();117 }, [measure, total, siblingCount, boundaryCount, size, shape, showPrevNext, showLabels]);118 119 React.useEffect(() => {120 const list = listRef.current;121 if (!list || typeof ResizeObserver === "undefined") return;122 const ro = new ResizeObserver(() => measure());123 ro.observe(list);124 return () => ro.disconnect();125 }, [measure]);126 127 return (128 <nav129 aria-label={ariaLabel}130 className={`${styles.root} ${className}`.trim()}131 data-variant={variant}132 data-shape={shape}133 data-size={size}134 data-disabled={disabled ? "" : undefined}135 style={{ "--mf-pg-accent": accentColor, ...style } as React.CSSProperties}136 >137 <ul ref={listRef} className={styles.list}>138 {rect && (139 <motion.li140 aria-hidden="true"141 className={styles.indicator}142 initial={false}143 animate={{ x: rect.x, y: rect.y, width: rect.w, height: rect.h }}144 transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 480, damping: 38 }}145 />146 )}147 {showPrevNext && (148 <li>149 <button type="button" className={`${styles.btn} ${styles.nav}`} aria-label="Go to previous page" disabled={disabled || page <= 1} onClick={() => go(page - 1)}>150 <Chevron dir="left" />151 {showLabels && <span>Previous</span>}152 </button>153 </li>154 )}155 {slots.map((s) =>156 typeof s === "number" ? (157 <li key={s}>158 <button159 ref={(el) => {160 if (el) btnRefs.current.set(s, el);161 else btnRefs.current.delete(s);162 }}163 type="button"164 className={`${styles.btn} ${styles.num}`}165 aria-label={`Page ${s}`}166 aria-current={s === page ? "page" : undefined}167 data-active={s === page ? "" : undefined}168 disabled={disabled}169 onClick={() => go(s)}170 >171 {s}172 </button>173 </li>174 ) : (175 <li key={s} aria-hidden="true" className={styles.ellipsis}>176 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">177 <circle cx="5" cy="12" r="1.6" />178 <circle cx="12" cy="12" r="1.6" />179 <circle cx="19" cy="12" r="1.6" />180 </svg>181 </li>182 ),183 )}184 {showPrevNext && (185 <li>186 <button type="button" className={`${styles.btn} ${styles.nav}`} aria-label="Go to next page" disabled={disabled || page >= total} onClick={() => go(page + 1)}>187 {showLabels && <span>Next</span>}188 <Chevron dir="right" />189 </button>190 </li>191 )}192 </ul>193 <span className={styles.srOnly} aria-live="polite">{`Page ${page} of ${total}`}</span>194 </nav>195 );196}