React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

paginationnavigationtablelistcomponenttailwind

Pagination

A control for navigating between pages of content, allowing users to move through large datasets or content collections in manageable chunks.

Use Cases

  • Table data with hundreds or thousands of rows
  • Blog post listings and article archives
  • Search results pages
  • Product catalog browsing in e-commerce
  • Image galleries and media libraries
  • Comment threads with many replies
  • Admin dashboards with audit logs or user lists

Simplest Implementation

"use client";
 
interface PaginationProps {
  currentPage: number;
  totalPages: number;
  onPageChange: (page: number) => void;
}
 
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
  return (
    <nav aria-label="Pagination" className="flex items-center gap-1">
      {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
        <button
          key={page}
          onClick={() => onPageChange(page)}
          aria-current={page === currentPage ? "page" : undefined}
          className={`rounded-lg px-3 py-2 text-sm font-medium ${
            page === currentPage
              ? "bg-blue-600 text-white"
              : "text-gray-700 hover:bg-gray-100"
          }`}
        >
          {page}
        </button>
      ))}
    </nav>
  );
}

A minimal numbered pagination. The active page is highlighted with a solid background, and aria-current="page" marks the current page for screen readers.

Variations

With Previous and Next Buttons

"use client";
 
interface PaginationProps {
  currentPage: number;
  totalPages: number;
  onPageChange: (page: number) => void;
}
 
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
  return (
    <nav aria-label="Pagination" className="flex items-center gap-1">
      <button
        onClick={() => onPageChange(currentPage - 1)}
        disabled={currentPage <= 1}
        className="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
      >
        Previous
      </button>
 
      {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
        <button
          key={page}
          onClick={() => onPageChange(page)}
          aria-current={page === currentPage ? "page" : undefined}
          className={`rounded-lg px-3 py-2 text-sm font-medium ${
            page === currentPage
              ? "bg-blue-600 text-white"
              : "text-gray-700 hover:bg-gray-100"
          }`}
        >
          {page}
        </button>
      ))}
 
      <button
        onClick={() => onPageChange(currentPage + 1)}
        disabled={currentPage >= totalPages}
        className="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
      >
        Next
      </button>
    </nav>
  );
}

Adds Previous and Next buttons that disable at the boundaries. This is the most common pagination pattern and works well when the total page count is small enough to show all numbers.

With Ellipsis for Large Page Counts

"use client";
 
interface PaginationProps {
  currentPage: number;
  totalPages: number;
  onPageChange: (page: number) => void;
  siblingCount?: number;
}
 
function generatePages(current: number, total: number, siblings: number): (number | "ellipsis")[] {
  const range = (start: number, end: number) =>
    Array.from({ length: end - start + 1 }, (_, i) => start + i);
 
  const leftSibling = Math.max(current - siblings, 2);
  const rightSibling = Math.min(current + siblings, total - 1);
 
  const showLeftEllipsis = leftSibling > 2;
  const showRightEllipsis = rightSibling < total - 1;
 
  if (!showLeftEllipsis && !showRightEllipsis) {
    return range(1, total);
  }
 
  if (!showLeftEllipsis && showRightEllipsis) {
    const leftRange = range(1, Math.max(rightSibling, 2 + siblings * 2));
    return [...leftRange, "ellipsis", total];
  }
 
  if (showLeftEllipsis && !showRightEllipsis) {
    const rightRange = range(Math.min(leftSibling, total - 1 - siblings * 2), total);
    return [1, "ellipsis", ...rightRange];
  }
 
  return [1, "ellipsis", ...range(leftSibling, rightSibling), "ellipsis", total];
}
 
export function Pagination({ currentPage, totalPages, onPageChange, siblingCount = 1 }: PaginationProps) {
  const pages = generatePages(currentPage, totalPages, siblingCount);
 
  return (
    <nav aria-label="Pagination" className="flex items-center gap-1">
      <button
        onClick={() => onPageChange(currentPage - 1)}
        disabled={currentPage <= 1}
        className="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
      >
        Previous
      </button>
 
      {pages.map((page, index) =>
        page === "ellipsis" ? (
          <span key={`ellipsis-${index}`} className="px-2 py-2 text-sm text-gray-400">
            ...
          </span>
        ) : (
          <button
            key={page}
            onClick={() => onPageChange(page)}
            aria-current={page === currentPage ? "page" : undefined}
            className={`rounded-lg px-3 py-2 text-sm font-medium ${
              page === currentPage
                ? "bg-blue-600 text-white"
                : "text-gray-700 hover:bg-gray-100"
            }`}
          >
            {page}
          </button>
        )
      )}
 
      <button
        onClick={() => onPageChange(currentPage + 1)}
        disabled={currentPage >= totalPages}
        className="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
      >
        Next
      </button>
    </nav>
  );
}

The generatePages function calculates which page numbers to show, inserting ellipsis where pages are skipped. The siblingCount controls how many pages appear around the current page.

Compact (Mobile-Friendly)

"use client";
 
interface PaginationProps {
  currentPage: number;
  totalPages: number;
  onPageChange: (page: number) => void;
}
 
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
  return (
    <nav aria-label="Pagination" className="flex items-center justify-between gap-4">
      <button
        onClick={() => onPageChange(currentPage - 1)}
        disabled={currentPage <= 1}
        className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none"
      >
        Previous
      </button>
 
      <span className="text-sm text-gray-600">
        Page <span className="font-medium">{currentPage}</span> of{" "}
        <span className="font-medium">{totalPages}</span>
      </span>
 
      <button
        onClick={() => onPageChange(currentPage + 1)}
        disabled={currentPage >= totalPages}
        className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none"
      >
        Next
      </button>
    </nav>
  );
}

A minimal layout with just Previous/Next buttons and a page indicator. Works well on narrow screens where showing individual page numbers would be crowded.

With Page Size Selector

"use client";
 
interface PaginationProps {
  currentPage: number;
  totalPages: number;
  pageSize: number;
  onPageChange: (page: number) => void;
  onPageSizeChange: (size: number) => void;
  pageSizeOptions?: number[];
}
 
export function Pagination({
  currentPage,
  totalPages,
  pageSize,
  onPageChange,
  onPageSizeChange,
  pageSizeOptions = [10, 25, 50, 100],
}: PaginationProps) {
  function handlePageSizeChange(e: React.ChangeEvent<HTMLSelectElement>) {
    onPageSizeChange(Number(e.target.value));
    onPageChange(1); // Reset to first page when page size changes
  }
 
  return (
    <nav aria-label="Pagination" className="flex items-center justify-between gap-4">
      <div className="flex items-center gap-2">
        <label htmlFor="page-size" className="text-sm text-gray-600">
          Rows per page:
        </label>
        <select
          id="page-size"
          value={pageSize}
          onChange={handlePageSizeChange}
          className="rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm text-gray-700"
        >
          {pageSizeOptions.map((size) => (
            <option key={size} value={size}>
              {size}
            </option>
          ))}
        </select>
      </div>
 
      <div className="flex items-center gap-1">
        <button
          onClick={() => onPageChange(currentPage - 1)}
          disabled={currentPage <= 1}
          className="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
        >
          Previous
        </button>
 
        <span className="px-3 py-2 text-sm text-gray-600">
          {currentPage} / {totalPages}
        </span>
 
        <button
          onClick={() => onPageChange(currentPage + 1)}
          disabled={currentPage >= totalPages}
          className="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
        >
          Next
        </button>
      </div>
    </nav>
  );
}

Combines navigation with a page size dropdown. When the user changes the page size, the component resets to page 1 to avoid landing on an out-of-range page.

Cursor-Based / Load More

"use client";
 
import { useState, useTransition } from "react";
 
interface LoadMoreProps<T> {
  initialItems: T[];
  fetchMore: (cursor: string) => Promise<{ items: T[]; nextCursor: string | null }>;
  initialCursor: string | null;
  renderItem: (item: T) => React.ReactNode;
}
 
export function LoadMore<T>({
  initialItems,
  fetchMore,
  initialCursor,
  renderItem,
}: LoadMoreProps<T>) {
  const [items, setItems] = useState<T[]>(initialItems);
  const [cursor, setCursor] = useState<string | null>(initialCursor);
  const [isPending, startTransition] = useTransition();
 
  function handleLoadMore() {
    if (!cursor) return;
    startTransition(async () => {
      const result = await fetchMore(cursor);
      setItems((prev) => [...prev, ...result.items]);
      setCursor(result.nextCursor);
    });
  }
 
  return (
    <div>
      <div>{items.map(renderItem)}</div>
 
      {cursor && (
        <div className="mt-6 flex justify-center">
          <button
            onClick={handleLoadMore}
            disabled={isPending}
            className="rounded-lg border border-gray-300 px-6 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
          >
            {isPending ? (
              <span className="inline-flex items-center gap-2">
                <svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
                  <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
                  <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
                </svg>
                Loading...
              </span>
            ) : (
              "Load more"
            )}
          </button>
        </div>
      )}
    </div>
  );
}
 
// Usage
// <LoadMore
//   initialItems={posts}
//   initialCursor={nextCursor}
//   fetchMore={async (cursor) => {
//     const res = await fetch(`/api/posts?cursor=${cursor}`);
//     return res.json();
//   }}
//   renderItem={(post) => <PostCard key={post.id} post={post} />}
// />

A cursor-based pagination alternative that appends items instead of replacing them. Uses useTransition to keep the UI responsive during fetching. The button disappears when there are no more items (nextCursor is null).

With Total Count Display

"use client";
 
interface PaginationProps {
  currentPage: number;
  totalPages: number;
  pageSize: number;
  totalItems: number;
  onPageChange: (page: number) => void;
}
 
export function Pagination({
  currentPage,
  totalPages,
  pageSize,
  totalItems,
  onPageChange,
}: PaginationProps) {
  const start = (currentPage - 1) * pageSize + 1;
  const end = Math.min(currentPage * pageSize, totalItems);
 
  return (
    <nav aria-label="Pagination" className="flex items-center justify-between">
      <p className="text-sm text-gray-600">
        Showing <span className="font-medium">{start}</span> to{" "}
        <span className="font-medium">{end}</span> of{" "}
        <span className="font-medium">{totalItems.toLocaleString()}</span> results
      </p>
 
      <div className="flex items-center gap-1">
        <button
          onClick={() => onPageChange(1)}
          disabled={currentPage <= 1}
          aria-label="First page"
          className="rounded-lg px-2 py-2 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
        >
          <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7M18 19l-7-7 7-7" />
          </svg>
        </button>
 
        <button
          onClick={() => onPageChange(currentPage - 1)}
          disabled={currentPage <= 1}
          aria-label="Previous page"
          className="rounded-lg px-2 py-2 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
        >
          <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
          </svg>
        </button>
 
        <span className="px-3 py-2 text-sm font-medium text-gray-700">
          {currentPage} / {totalPages}
        </span>
 
        <button
          onClick={() => onPageChange(currentPage + 1)}
          disabled={currentPage >= totalPages}
          aria-label="Next page"
          className="rounded-lg px-2 py-2 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
        >
          <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
          </svg>
        </button>
 
        <button
          onClick={() => onPageChange(totalPages)}
          disabled={currentPage >= totalPages}
          aria-label="Last page"
          className="rounded-lg px-2 py-2 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none"
        >
          <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M6 5l7 7-7 7" />
          </svg>
        </button>
      </div>
    </nav>
  );
}

Shows "Showing X to Y of Z results" alongside first/prev/next/last icon buttons. The range calculation accounts for the last page potentially having fewer items than the page size.

Complex Implementation

"use client";
 
import { useMemo, useCallback } from "react";
 
// --- Types ---
 
type Variant = "numbered" | "compact" | "simple";
 
interface PaginationProps {
  currentPage: number;
  totalPages: number;
  totalItems?: number;
  pageSize?: number;
  siblingCount?: number;
  variant?: Variant;
  onPageChange: (page: number) => void;
  className?: string;
}
 
// --- Page range generator ---
 
function generatePageRange(
  current: number,
  total: number,
  siblings: number
): (number | "ellipsis-start" | "ellipsis-end")[] {
  if (total <= siblings * 2 + 5) {
    return Array.from({ length: total }, (_, i) => i + 1);
  }
 
  const leftSibling = Math.max(current - siblings, 2);
  const rightSibling = Math.min(current + siblings, total - 1);
  const showLeftEllipsis = leftSibling > 2;
  const showRightEllipsis = rightSibling < total - 1;
 
  const pages: (number | "ellipsis-start" | "ellipsis-end")[] = [1];
 
  if (showLeftEllipsis) {
    pages.push("ellipsis-start");
  } else {
    for (let i = 2; i < leftSibling; i++) pages.push(i);
  }
 
  for (let i = leftSibling; i <= rightSibling; i++) pages.push(i);
 
  if (showRightEllipsis) {
    pages.push("ellipsis-end");
  } else {
    for (let i = rightSibling + 1; i < total; i++) pages.push(i);
  }
 
  pages.push(total);
  return pages;
}
 
// --- Sub-components ---
 
function NavButton({
  onClick,
  disabled,
  label,
  children,
}: {
  onClick: () => void;
  disabled: boolean;
  label: string;
  children: React.ReactNode;
}) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      aria-label={label}
      className="inline-flex h-9 w-9 items-center justify-center rounded-lg text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:pointer-events-none"
    >
      {children}
    </button>
  );
}
 
function PageButton({
  page,
  active,
  onClick,
}: {
  page: number;
  active: boolean;
  onClick: () => void;
}) {
  return (
    <button
      onClick={onClick}
      aria-current={active ? "page" : undefined}
      className={`inline-flex h-9 min-w-[36px] items-center justify-center rounded-lg px-2 text-sm font-medium transition-colors ${
        active
          ? "bg-blue-600 text-white shadow-sm"
          : "text-gray-700 hover:bg-gray-100"
      }`}
    >
      {page}
    </button>
  );
}
 
// --- Main Component ---
 
export function Pagination({
  currentPage,
  totalPages,
  totalItems,
  pageSize,
  siblingCount = 1,
  variant = "numbered",
  onPageChange,
  className,
}: PaginationProps) {
  const pages = useMemo(
    () => generatePageRange(currentPage, totalPages, siblingCount),
    [currentPage, totalPages, siblingCount]
  );
 
  const goTo = useCallback(
    (page: number) => {
      const clamped = Math.max(1, Math.min(page, totalPages));
      if (clamped !== currentPage) onPageChange(clamped);
    },
    [currentPage, totalPages, onPageChange]
  );
 
  if (totalPages <= 1) return null;
 
  // Range text
  const rangeText =
    totalItems != null && pageSize != null
      ? `${((currentPage - 1) * pageSize + 1).toLocaleString()}\u2013${Math.min(
          currentPage * pageSize,
          totalItems
        ).toLocaleString()} of ${totalItems.toLocaleString()}`
      : null;
 
  const prevDisabled = currentPage <= 1;
  const nextDisabled = currentPage >= totalPages;
 
  const prevArrow = (
    <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
    </svg>
  );
 
  const nextArrow = (
    <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
    </svg>
  );
 
  return (
    <nav
      aria-label="Pagination"
      className={`flex items-center ${
        variant === "simple" ? "justify-between" : "justify-center gap-1"
      } ${className ?? ""}`}
    >
      {/* Total items range (numbered and compact) */}
      {rangeText && variant === "numbered" && (
        <p className="mr-auto text-sm text-gray-500">{rangeText}</p>
      )}
 
      {/* Simple variant: prev / page info / next */}
      {variant === "simple" && (
        <>
          <button
            onClick={() => goTo(currentPage - 1)}
            disabled={prevDisabled}
            className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none"
          >
            Previous
          </button>
          <span className="text-sm text-gray-600">
            Page <span className="font-medium">{currentPage}</span> of{" "}
            <span className="font-medium">{totalPages}</span>
          </span>
          <button
            onClick={() => goTo(currentPage + 1)}
            disabled={nextDisabled}
            className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none"
          >
            Next
          </button>
        </>
      )}
 
      {/* Compact variant: just arrows and page count */}
      {variant === "compact" && (
        <>
          <NavButton onClick={() => goTo(currentPage - 1)} disabled={prevDisabled} label="Previous page">
            {prevArrow}
          </NavButton>
          <span className="px-2 text-sm text-gray-600">
            {currentPage} / {totalPages}
          </span>
          <NavButton onClick={() => goTo(currentPage + 1)} disabled={nextDisabled} label="Next page">
            {nextArrow}
          </NavButton>
        </>
      )}
 
      {/* Numbered variant: full page buttons */}
      {variant === "numbered" && (
        <>
          <NavButton onClick={() => goTo(currentPage - 1)} disabled={prevDisabled} label="Previous page">
            {prevArrow}
          </NavButton>
 
          {pages.map((page) =>
            typeof page === "string" ? (
              <span key={page} className="inline-flex h-9 w-9 items-center justify-center text-sm text-gray-400">
                ...
              </span>
            ) : (
              <PageButton
                key={page}
                page={page}
                active={page === currentPage}
                onClick={() => goTo(page)}
              />
            )
          )}
 
          <NavButton onClick={() => goTo(currentPage + 1)} disabled={nextDisabled} label="Next page">
            {nextArrow}
          </NavButton>
        </>
      )}
    </nav>
  );
}

Key aspects:

  • Three variant modesnumbered shows page buttons with ellipsis, compact shows just arrows with a counter, and simple shows text Previous/Next buttons. This covers desktop, mobile, and minimal use cases in one component.
  • Stable page range generation — the generatePageRange function is memoized and produces unique string keys for ellipsis ("ellipsis-start" / "ellipsis-end") to avoid React key conflicts when both appear.
  • Clamped navigationgoTo clamps the target page to [1, totalPages] and skips the callback if the page hasn't changed, preventing redundant state updates and API calls.
  • Range text display — when totalItems and pageSize are provided, a "1-25 of 500" label appears, giving users context on how much data exists.
  • Hidden when single page — the component returns null when totalPages <= 1, avoiding unnecessary UI for small datasets.
  • Accessible button labeling — each navigation button has a descriptive aria-label and the active page button uses aria-current="page" for screen reader context.
  • Composable sub-componentsNavButton and PageButton are extracted to keep the main render clean and make each button's styling independently maintainable.

Gotchas

  • Not clamping page values — If currentPage exceeds totalPages (e.g., after filtering reduces results), the component may render an invalid state. Always clamp or reset the page when total count changes.

  • Forgetting to reset to page 1 on filter/search change — When a user applies a filter, they should be taken back to page 1. Staying on page 5 of a new result set is confusing.

  • Generating all page buttons for large datasets — Rendering 1,000 page buttons crashes the UI. Always use an ellipsis strategy for datasets with more than about 7 pages.

  • Off-by-one errors in range display — "Showing 0 to 0 of 0" looks broken on empty states. Handle the empty case separately and show a message like "No results" instead of the pagination component.

  • Cursor-based APIs with page numbers — If your backend uses cursor-based pagination, you cannot jump to arbitrary page numbers. Use a Load More or infinite scroll pattern instead of numbered pages.

  • Missing keyboard navigation — Users expect to use arrow keys or Tab to move between page buttons. The default button focus behavior handles Tab, but consider adding arrow key support for grouped controls.

  • URL state not synced with page — If pagination state lives only in React state, refreshing the page loses position. Sync currentPage and pageSize with URL search params using useSearchParams.

  • Breadcrumb — Navigation trail that complements paginated views
  • Button — Base interactive element used in pagination controls
  • Forms — Form patterns for page size selection