Pagination Mini
Navigation

Pagination Mini

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.

Install

npx shadcn@latest add @paragon/pagination-mini

pagination-mini.tsx

"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>
  );
}