Skip to content
Corsair UI
Browse the docs

ComponentsNo. 38 of 109

Pagination

Page links with previous and next, an ellipsis, and a helper that works out which page numbers to show.

Installation

pnpm dlx shadcn@latest add @corsair-ui/pagination

No setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.

Also adds
utilsbutton
npm
@radix-ui/react-slotclass-variance-authorityclsxlucide-reacttailwind-merge

Examples

Controlled

getPaginationRange picks the numbers; Previous and Next lose their href at the ends.

Log page 1 of 12

API

PropTypeDefault
isActive

PaginationLink: the current page, with aria-current="page".

booleanfalse
label

PaginationPrevious and Next: visible text, from sm up.

string"Previous" / "Next"
asChild

PaginationLink: render your router's link.

booleanfalse
getPaginationRange

The numbers to show; siblings and boundaries default to 1.

({ page, pageCount, siblings?, boundaries? }) => (number | "ellipsis")[]—

Accessibility

  • A nav named "Pagination"; the current page has aria-current="page".
  • Previous and Next are named "Go to previous page" and "Go to next page".
  • At the ends, leave off href and pass aria-disabled: they dim and leave the Tab order.

Compatibility

Tailwind CSS
3.4 and 4, both checked in CI
React
19
Rendering
Works in server components

Source

1 file, installed as source you own
pagination.tsxView on GitHub
import { Slot, Slottable } from "@radix-ui/react-slot";
import type { VariantProps } from "class-variance-authority";
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { cn } from "@/registry/default/lib/utils";
import { buttonVariants } from "@/registry/default/ui/button";
/**
 * Links to the pages of a long list: search results, a blog archive, a
 * table. It is a `<nav>` landmark named "Pagination" (change it with
 * `aria-label`) around a list of links, so Tab moves through them and Enter
 * follows one. The current page is announced with `aria-current="page"`.
 * Use `getPaginationRange` to work out which numbers to show. On the first
 * or last page, leave `href` off Previous / Next and pass `aria-disabled`,
 * which dims them and takes them out of the Tab order. Nothing animates.
 *
 * @example
 * <Pagination>
 *   <PaginationContent>
 *     <PaginationItem>
 *       <PaginationPrevious href={`?page=${page - 1}`} />
 *     </PaginationItem>
 *     {getPaginationRange({ page, pageCount }).map((entry, index) => (
 *       <PaginationItem key={entry === "ellipsis" ? `ellipsis-${index}` : entry}>
 *         {entry === "ellipsis" ? (
 *           <PaginationEllipsis />
 *         ) : (
 *           <PaginationLink href={`?page=${entry}`} isActive={entry === page}>
 *             {entry}
 *           </PaginationLink>
 *         )}
 *       </PaginationItem>
 *     ))}
 *     <PaginationItem>
 *       <PaginationNext href={`?page=${page + 1}`} />
 *     </PaginationItem>
 *   </PaginationContent>
 * </Pagination>
 */
function Pagination({
  className,
  "aria-label": label = "Pagination",
  ...props
}: ComponentProps<"nav">) {
  return (
    <nav
      data-slot="pagination"
      aria-label={label}
      className={cn("mx-auto flex w-full justify-center", className)}
      {...props}
    />
  );
}
function PaginationContent({ className, ...props }: ComponentProps<"ul">) {
  return (
    <ul
      data-slot="pagination-content"
      className={cn("flex flex-row flex-wrap items-center justify-center gap-1", className)}
      {...props}
    />
  );
}
function PaginationItem(props: ComponentProps<"li">) {
  return <li data-slot="pagination-item" {...props} />;
}
interface PaginationLinkProps
  extends ComponentProps<"a">, Pick<VariantProps<typeof buttonVariants>, "size"> {
  /** Marks the link as the current page: `aria-current="page"`, `data-active` and the outline style. */
  isActive?: boolean;
  /** Render the single child (next/link, a router Link) with the link styles instead. */
  asChild?: boolean;
}
/** A link to one page, styled as a square button. Pass `asChild` to use your framework's link component. */
function PaginationLink({
  className,
  isActive = false,
  size = "icon",
  asChild = false,
  ...props
}: PaginationLinkProps) {
  const Comp = asChild ? Slot : "a";
  // "outline" is the button variant name, not the Tailwind utility.
  // tailwind-compat-ignore-next-line
  const variant = isActive ? "outline" : "ghost";
  return (
    <Comp
      data-slot="pagination-link"
      data-active={isActive}
      aria-current={isActive ? "page" : undefined}
      className={cn(buttonVariants({ variant, size }), className)}
      {...props}
    />
  );
}
interface PaginationStepProps extends Omit<PaginationLinkProps, "isActive"> {
  /** Visible text next to the arrow, hidden below the `sm` breakpoint. */
  label?: string;
}
/**
 * Link to the previous page. Its accessible name is "Go to previous page"
 * (change it with `aria-label`); `label` is the visible text, shown from
 * `sm` up. With `asChild`, the arrow and text go inside your link component.
 */
function PaginationPrevious({
  label = "Previous",
  "aria-label": ariaLabel = "Go to previous page",
  className,
  children,
  ...props
}: PaginationStepProps) {
  return (
    <PaginationLink
      data-slot="pagination-previous"
      aria-label={ariaLabel}
      size="default"
      className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
      {...props}
    >
      <ChevronLeftIcon aria-hidden="true" />
      <Slottable>{children}</Slottable>
      <span className="hidden sm:block">{label}</span>
    </PaginationLink>
  );
}
/**
 * Link to the next page. Its accessible name is "Go to next page" (change
 * it with `aria-label`); `label` is the visible text, shown from `sm` up.
 * With `asChild`, the text and arrow go inside your link component.
 */
function PaginationNext({
  label = "Next",
  "aria-label": ariaLabel = "Go to next page",
  className,
  children,
  ...props
}: PaginationStepProps) {
  return (
    <PaginationLink
      data-slot="pagination-next"
      aria-label={ariaLabel}
      size="default"
      className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
      {...props}
    >
      <span className="hidden sm:block">{label}</span>
      <Slottable>{children}</Slottable>
      <ChevronRightIcon aria-hidden="true" />
    </PaginationLink>
  );
}
interface PaginationEllipsisProps extends ComponentProps<"span"> {
  /** Text for screen readers in place of the dots. */
  label?: string;
}
/** Stands in for the page numbers that are left out. The dots are hidden; `label` is read instead. */
function PaginationEllipsis({
  label = "More pages",
  className,
  ...props
}: PaginationEllipsisProps) {
  return (
    <span
      data-slot="pagination-ellipsis"
      className={cn("flex size-9 items-center justify-center", className)}
      {...props}
    >
      <MoreHorizontalIcon aria-hidden="true" className="size-4" />
      <span className="sr-only">{label}</span>
    </span>
  );
}
interface PaginationRangeOptions {
  /** The current page, starting at 1. Clamped to 1 … `pageCount`. */
  page: number;
  /** How many pages there are. */
  pageCount: number;
  /** Pages shown on each side of the current one. */
  siblings?: number;
  /** Pages always shown at the start and at the end. */
  boundaries?: number;
}
function range(start: number, end: number) {
  return Array.from({ length: Math.max(0, end - start + 1) }, (_, index) => start + index);
}
/**
 * Works out which page numbers to render: the first and last `boundaries`
 * pages, `siblings` pages either side of the current one, and "ellipsis"
 * where pages are left out. An ellipsis only replaces two or more pages, and
 * the result has the same length wherever the current page is, so the
 * links do not shift as you move through them.
 *
 * @example
 * getPaginationRange({ page: 5, pageCount: 10 });
 * // [1, "ellipsis", 4, 5, 6, "ellipsis", 10]
 */
function getPaginationRange({
  page,
  pageCount,
  siblings = 1,
  boundaries = 1,
}: PaginationRangeOptions): (number | "ellipsis")[] {
  const total = Math.max(0, Math.floor(pageCount));
  if (total === 0) return [];
  const around = Math.max(0, Math.floor(siblings));
  const edges = Math.max(0, Math.floor(boundaries));
  const current = Math.min(total, Math.max(1, Math.floor(page)));
  // Edges, siblings, the current page and room for two ellipses.
  const slots = around * 2 + 3 + edges * 2;
  if (slots >= total) return range(1, total);
  const left = Math.max(current - around, edges);
  const right = Math.min(current + around, total - edges);
  const leftGap = left > edges + 2;
  const rightGap = right < total - (edges + 1);
  if (!leftGap && rightGap) {
    return [...range(1, around * 2 + edges + 2), "ellipsis", ...range(total - edges + 1, total)];
  }
  if (leftGap && !rightGap) {
    return [...range(1, edges), "ellipsis", ...range(total - (edges + 1 + around * 2), total)];
  }
  return [
    ...range(1, edges),
    "ellipsis",
    ...range(left, right),
    "ellipsis",
    ...range(total - edges + 1, total),
  ];
}
export {
  getPaginationRange,
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
  type PaginationEllipsisProps,
  type PaginationLinkProps,
  type PaginationRangeOptions,
  type PaginationStepProps,
};