Navigation

Pagination

Page navigation with a measured pill that slides between numbers, ellipsis collapsing, and hover-nudged chevrons.

Install

npx shadcn@latest add @paragon/pagination

pagination.tsx

"use client";

import * as React from "react";
import { ChevronLeft, ChevronRight, Ellipsis } from "lucide-react";
import { cn } from "@/lib/utils";

type PaginationSlot = number | "ellipsis-start" | "ellipsis-end";

function range(start: number, end: number): number[] {
  return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}

function getPaginationRange(
  page: number,
  totalPages: number,
  siblingCount: number,
): PaginationSlot[] {
  // first + last + current + siblings on both sides + two ellipsis slots
  const totalSlots = siblingCount * 2 + 5;
  if (totalPages <= totalSlots) return range(1, totalPages);

  const leftSibling = Math.max(page - siblingCount, 1);
  const rightSibling = Math.min(page + siblingCount, totalPages);
  const showLeftEllipsis = leftSibling > 2;
  const showRightEllipsis = rightSibling < totalPages - 1;

  if (!showLeftEllipsis && showRightEllipsis) {
    return [...range(1, 3 + 2 * siblingCount), "ellipsis-end", totalPages];
  }
  if (showLeftEllipsis && !showRightEllipsis) {
    return [
      1,
      "ellipsis-start",
      ...range(totalPages - (2 + 2 * siblingCount), totalPages),
    ];
  }
  return [
    1,
    "ellipsis-start",
    ...range(leftSibling, rightSibling),
    "ellipsis-end",
    totalPages,
  ];
}

export interface PaginationProps
  extends Omit<React.ComponentProps<"nav">, "onChange"> {
  totalPages: number;
  page?: number;
  defaultPage?: number;
  onPageChange?: (page: number) => void;
  /** Pages shown on each side of the current page. */
  siblingCount?: number;
  /** Disables the sliding pill motion; selection snaps instantly. */
  static?: boolean;
}

/**
 * Page navigation with a single measured pill that slides between page
 * numbers (offsetLeft/offsetWidth, transform + width transition — the same
 * technique as animated-tabs) and prev/next chevrons that nudge 2px on
 * hover, gated to fine pointers. Long ranges collapse into ellipses.
 */
export function Pagination({
  totalPages,
  page: pageProp,
  defaultPage = 1,
  onPageChange,
  siblingCount = 1,
  static: isStatic = false,
  className,
  ...props
}: PaginationProps) {
  const [internalPage, setInternalPage] = React.useState(defaultPage);
  const page = Math.min(Math.max(pageProp ?? internalPage, 1), totalPages);

  const containerRef = React.useRef<HTMLDivElement>(null);
  const pillRef = React.useRef<HTMLSpanElement>(null);
  const hasPositioned = React.useRef(false);

  const setPage = (next: number) => {
    const clamped = Math.min(Math.max(next, 1), totalPages);
    if (clamped === page) return;
    setInternalPage(clamped);
    onPageChange?.(clamped);
  };

  const position = React.useCallback((animate: boolean) => {
    const container = containerRef.current;
    const pill = pillRef.current;
    if (!container || !pill) return;
    const active = container.querySelector<HTMLElement>(
      '[aria-current="page"]',
    );
    if (!active) {
      pill.style.opacity = "0";
      return;
    }
    if (!animate) pill.style.transitionProperty = "none";
    pill.style.opacity = "1";
    pill.style.width = `${active.offsetWidth}px`;
    pill.style.transform = `translateX(${active.offsetLeft}px)`;
    if (!animate) {
      void pill.offsetWidth;
      pill.style.transitionProperty = "";
    }
  }, []);

  React.useLayoutEffect(() => {
    position(hasPositioned.current && !isStatic);
    hasPositioned.current = true;
  }, [position, page, totalPages, isStatic]);

  React.useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
    const observer = new ResizeObserver(() => position(false));
    observer.observe(container);
    return () => observer.disconnect();
  }, [position]);

  const slots = getPaginationRange(page, totalPages, siblingCount);

  const navButtonClass =
    "group relative inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out after:absolute after:inset-x-0 after:-inset-y-1 hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40";

  return (
    <nav aria-label="Pagination" className={cn("w-fit", className)} {...props}>
      <div ref={containerRef} className="relative flex items-center gap-1">
        <span
          ref={pillRef}
          aria-hidden
          className="pointer-events-none absolute top-0 left-0 h-8 rounded-md bg-primary opacity-0 [transition-property:transform,width] duration-[250ms] ease-out motion-reduce:transition-none"
        />
        <button
          type="button"
          aria-label="Previous page"
          disabled={page <= 1}
          onClick={() => setPage(page - 1)}
          className={navButtonClass}
        >
          <ChevronLeft className="size-4 transition-transform duration-150 ease-out [@media(hover:hover)_and_(pointer:fine)]:group-hover:-translate-x-0.5" />
        </button>
        {slots.map((slot) =>
          typeof slot === "number" ? (
            <button
              key={slot}
              type="button"
              aria-current={slot === page ? "page" : undefined}
              aria-label={`Page ${slot}`}
              onClick={() => setPage(slot)}
              className="relative z-10 inline-flex h-8 min-w-8 items-center justify-center rounded-md px-1.5 text-[13px] font-medium text-muted-foreground tabular-nums transition-colors duration-150 ease-out after:absolute after:inset-x-0 after:-inset-y-1 hover:text-foreground aria-[current=page]:text-primary-foreground"
            >
              {slot}
            </button>
          ) : (
            <span
              key={slot}
              aria-hidden
              className="flex size-8 items-center justify-center text-muted-foreground/60"
            >
              <Ellipsis className="size-3.5" />
            </span>
          ),
        )}
        <button
          type="button"
          aria-label="Next page"
          disabled={page >= totalPages}
          onClick={() => setPage(page + 1)}
          className={navButtonClass}
        >
          <ChevronRight className="size-4 transition-transform duration-150 ease-out [@media(hover:hover)_and_(pointer:fine)]:group-hover:translate-x-0.5" />
        </button>
      </div>
    </nav>
  );
}