UINavigationmotion/react

Pagination

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 props
4
12
1
1

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add pagination

Writes 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.

example.tsx
1import { Pagination } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <Pagination />6  );7}

Props

PropTypeDefault
page

Controlled current page (1-based).

numberundefined
defaultPage

Initial page when uncontrolled.

number4
onPageChange

Called with the next page.

(page: number) => voidundefined
totalPages

Total number of pages.

number12
siblingCount

Pages shown on each side of the current page.

number1
boundaryCount

Pages always shown at the start and end.

number1
variant

Style of the sliding active indicator.

'solid' | 'outline' | 'soft''solid'
shape

Button corner shape.

'rounded' | 'circle''rounded'
size

Button size preset.

'sm' | 'md' | 'lg''md'
accentColor

Color of the active indicator.

string'#6366f1'
showPrevNext

Show Previous and Next buttons.

booleantrue
showLabels

Show text next to the Previous/Next chevrons.

booleantrue
disabled

Disable the whole control.

booleanfalse
ariaLabel

Accessible label of the nav landmark.

string'Pagination'
className

Class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/pagination.tsx
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}