Skip to content
goodtabs UI
BrowseBrowse components

Pager

Page navigation with prev and next pills, numbered pages and an ellipsis for long ranges.

npx shadcn@latest add https://ui.goodtabs.co/r/pager.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/pager

Page 3 of 12. One sibling on each side, first and last always shown.

Many pages

Page 48 of 120. An ellipsis on both sides of the current page.

Compact

Prev and next around a mono "Page 3 of 12" for tight toolbars.

Usage

import { Pager } from "@/components/ui/pager"

const [page, setPage] = useState(1)

<Pager page={page} pageCount={12} onPageChange={setPage} />
<Pager variant="compact" page={page} pageCount={12} onPageChange={setPage} />

Props

PropTypeDefaultDescription
pagenumberThe current page, from 1. Clamped to the page count.
pageCountnumberTotal number of pages.
onPageChange(page: number) => voidCalled with the page to go to.
siblingCountnumber1Pages shown on each side of the current one.
showEdgesbooleantrueAlways show the first and last page numbers.
variant"numbers" | "compact""numbers"compact drops the numbers for a mono "Page n of m" between the arrows.

Source

pager/pager.tsx
"use client";

import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type PagerProps = Omit<React.ComponentProps<"nav">, "children"> & {
  /** The current page, from 1. */
  page: number;
  /** Total number of pages. */
  pageCount: number;
  /** Called with the page to go to. */
  onPageChange: (page: number) => void;
  /** Pages shown on each side of the current one. */
  siblingCount?: number;
  /** Always show the first and last page numbers. */
  showEdges?: boolean;
  /** numbers shows the page pills; compact shows only prev, next and "Page n of m". */
  variant?: "numbers" | "compact";
};

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

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

/** First, last, the current page with its siblings, and an ellipsis for each gap. */
function getSlots(page: number, pageCount: number, siblingCount: number, showEdges: boolean): Slot[] {
  const boundary = showEdges ? 1 : 0;
  const total = boundary * 2 + siblingCount * 2 + 3;
  if (pageCount <= total) return range(1, pageCount);

  const startPages = range(1, boundary);
  const endPages = range(pageCount - boundary + 1, pageCount);

  const siblingsStart = Math.max(
    Math.min(page - siblingCount, pageCount - boundary - siblingCount * 2 - 1),
    boundary + 2,
  );
  const siblingsEnd = Math.min(
    Math.max(page + siblingCount, boundary + siblingCount * 2 + 2),
    pageCount - boundary - 1,
  );

  return [
    ...startPages,
    ...(siblingsStart > boundary + 2
      ? (["start-ellipsis"] as Slot[])
      : boundary + 1 < pageCount - boundary
        ? [boundary + 1]
        : []),
    ...range(siblingsStart, siblingsEnd),
    ...(siblingsEnd < pageCount - boundary - 1
      ? (["end-ellipsis"] as Slot[])
      : pageCount - boundary > boundary
        ? [pageCount - boundary]
        : []),
    ...endPages,
  ];
}

/**
 * Page navigation: prev and next as outline pills, page numbers as ghost pills,
 * the current one filled. Controlled: pass page and handle onPageChange.
 */
export function Pager({
  page,
  pageCount,
  onPageChange,
  siblingCount = 1,
  showEdges = true,
  variant = "numbers",
  className,
  ...props
}: PagerProps) {
  const count = Math.max(1, pageCount);
  const current = Math.min(Math.max(1, page), count);
  const go = (next: number) => {
    if (next !== current && next >= 1 && next <= count) onPageChange(next);
  };

  return (
    <nav
      data-slot="pager"
      aria-label="Pagination"
      className={cn("flex items-center gap-1", variant === "compact" && "gap-2", className)}
      {...props}
    >
      <Button
        type="button"
        variant="outline"
        size="icon-sm"
        className="rounded-full"
        aria-label="Previous page"
        disabled={current <= 1}
        onClick={() => go(current - 1)}
      >
        <ChevronLeftIcon aria-hidden="true" />
      </Button>

      {variant === "compact" ? (
        <span className="px-1 font-mono text-xs text-muted-foreground tabular-nums" aria-live="polite">
          Page {current} of {count}
        </span>
      ) : (
        <>
          <ul className="m-0 flex list-none items-center gap-1 p-0">
            {getSlots(current, count, Math.max(0, siblingCount), showEdges).map((slot) =>
              typeof slot === "number" ? (
                <li key={slot}>
                  <Button
                    type="button"
                    variant="ghost"
                    size="icon-sm"
                    aria-label={`Page ${slot}`}
                    aria-current={slot === current ? "page" : undefined}
                    onClick={() => go(slot)}
                    className={cn(
                      "w-auto min-w-7 rounded-full px-1.5 font-mono text-xs tabular-nums",
                      slot === current &&
                        "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground",
                    )}
                  >
                    {slot}
                  </Button>
                </li>
              ) : (
                <li key={slot} className="flex size-7 items-center justify-center text-muted-foreground">
                  <MoreHorizontalIcon className="size-4" aria-hidden="true" />
                  <span className="sr-only">More pages</span>
                </li>
              ),
            )}
          </ul>
          <span className="sr-only" aria-live="polite">
            Page {current} of {count}
          </span>
        </>
      )}

      <Button
        type="button"
        variant="outline"
        size="icon-sm"
        className="rounded-full"
        aria-label="Next page"
        disabled={current >= count}
        onClick={() => go(current + 1)}
      >
        <ChevronRightIcon aria-hidden="true" />
      </Button>
    </nav>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/pager/prompt.md, or the Copy AI prompt button above.

When to use

  • Under a table or list that is split into pages on the server
  • When people need to jump to a specific page, not just the next one
  • In a toolbar where only prev and next fit: use the compact variant

When not to use

  • For feeds that load more as you scroll: use a load more button instead
  • For steps in a flow, which have an order and a finish
  • When there is only one page: render nothing

Agent notes

  • Controlled only: it never stores the page, so pass page and update it in onPageChange
  • It takes a function, so the parent that renders it must be a client component
  • The current page is bg-primary with aria-current; prev and next disable at the edges
  • The row does not wrap; with many pages and a wide siblingCount, give it a scrolling box