Composite

Pagination

Pagination provides a prop-driven component for common list paging plus compound parts for custom layouts. Uses `buttonVariants` for consistent sizing and focus rings.

Installation

npx shadcn add https://rck.vibeboxph.com//r/pagination.json

Live preview & controls

Controls
<Pagination page={2} totalPages={10} onPageChange={() => {}} />

Anatomy

  • PaginationHigh-level component driven by `page`, `totalPages`, and `onPageChange`.
  • PaginationNavCompound `<nav aria-label="Pagination">` shell.
  • PaginationContentThe list of page links.
  • PaginationLink / PaginationButtonPage or navigation controls as links or buttons.
  • PaginationPrevious / PaginationNextDirectional navigation controls.

Props

PropTypeDefaultDescription
page / defaultPagenumberCurrent page (1-indexed).
totalPages*numberTotal number of pages.
onPageChange(page: number) => voidCalled when the user selects a new page.
getHref(page: number) => stringWhen set, renders anchor links instead of buttons.
siblingCountnumber1Pages shown on each side of the current page.
boundaryCountnumber1Pages always shown at the start and end.
showFirstLastbooleanfalseShow jump-to-first/last controls.
hideOnSinglePagebooleanfalseRender nothing when `totalPages` is 1.
isActive (PaginationLink)booleanMarks the current page with `aria-current="page"`.
size"sm" | "md" | "lg" | "icon"iconControl size from the Button scale.

Accessibility

  • Root uses `<nav aria-label="Pagination">` so the control group is announced as navigation.
  • Current page link sets `aria-current="page"`.
  • Previous/Next links include explicit `aria-label` values.
  • Disable prev/next at boundaries with `aria-disabled` and remove pointer interaction.

Source

registry/rck/ui/pagination.tsx
"use client";

import * as React from "react";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "@/lib/icons";
import { cn } from "@/lib/utils";
import { useControllableState } from "@/hooks/use-controllable-state";
import { buttonVariants } from "@/registry/rck/ui/button";

export function PaginationNav({
  className,
  "aria-label": ariaLabel = "Pagination",
  ...props
}: React.ComponentProps<"nav">) {
  return (
    <nav
      role="navigation"
      aria-label={ariaLabel}
      data-slot="pagination-nav"
      className={cn("mx-auto flex w-full justify-center", className)}
      {...props}
    />
  );
}

export function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
  return (
    <ul
      data-slot="pagination-content"
      className={cn("flex flex-row items-center gap-1", className)}
      {...props}
    />
  );
}

export function PaginationItem({ className, ...props }: React.ComponentProps<"li">) {
  return <li data-slot="pagination-item" className={cn(className)} {...props} />;
}

export interface PaginationLinkProps extends React.ComponentProps<"a"> {
  isActive?: boolean;
  size?: "sm" | "md" | "lg" | "icon";
}

export function PaginationLink({
  className,
  isActive,
  size = "icon",
  ...props
}: PaginationLinkProps) {
  return (
    <a
      aria-current={isActive ? "page" : undefined}
      data-slot="pagination-link"
      data-active={isActive}
      className={cn(
        buttonVariants({
          variant: isActive ? "solid" : "ghost",
          size,
        }),
        className,
      )}
      {...props}
    />
  );
}

export interface PaginationButtonProps extends React.ComponentProps<"button"> {
  isActive?: boolean;
  size?: "sm" | "md" | "lg" | "icon";
}

export function PaginationButton({
  className,
  isActive,
  size = "icon",
  type = "button",
  ...props
}: PaginationButtonProps) {
  return (
    <button
      type={type}
      aria-current={isActive ? "page" : undefined}
      data-slot="pagination-button"
      data-active={isActive}
      className={cn(
        buttonVariants({
          variant: isActive ? "solid" : "ghost",
          size,
        }),
        className,
      )}
      {...props}
    />
  );
}

export function PaginationPrevious({
  className,
  children,
  ...props
}: React.ComponentProps<typeof PaginationLink>) {
  return (
    <PaginationLink
      aria-label="Go to previous page"
      size="sm"
      className={cn("gap-1 px-2.5", className)}
      {...props}
    >
      <ChevronLeft className="size-4" aria-hidden="true" />
      <span>{children ?? "Previous"}</span>
    </PaginationLink>
  );
}

export function PaginationNext({
  className,
  children,
  ...props
}: React.ComponentProps<typeof PaginationLink>) {
  return (
    <PaginationLink
      aria-label="Go to next page"
      size="sm"
      className={cn("gap-1 px-2.5", className)}
      {...props}
    >
      <span>{children ?? "Next"}</span>
      <ChevronRight className="size-4" aria-hidden="true" />
    </PaginationLink>
  );
}

export function PaginationFirst({
  className,
  children,
  ...props
}: React.ComponentProps<typeof PaginationLink>) {
  return (
    <PaginationLink
      aria-label="Go to first page"
      size="sm"
      className={cn("gap-1 px-2.5", className)}
      {...props}
    >
      <ChevronLeft className="size-4" aria-hidden="true" />
      <ChevronLeft className="-ml-2 size-4" aria-hidden="true" />
      <span>{children ?? "First"}</span>
    </PaginationLink>
  );
}

export function PaginationLast({
  className,
  children,
  ...props
}: React.ComponentProps<typeof PaginationLink>) {
  return (
    <PaginationLink
      aria-label="Go to last page"
      size="sm"
      className={cn("gap-1 px-2.5", className)}
      {...props}
    >
      <span>{children ?? "Last"}</span>
      <ChevronRight className="size-4" aria-hidden="true" />
      <ChevronRight className="-ml-2 size-4" aria-hidden="true" />
    </PaginationLink>
  );
}

export function PaginationEllipsis({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      aria-hidden="true"
      data-slot="pagination-ellipsis"
      className={cn("flex size-9 items-center justify-center", className)}
      {...props}
    >
      <MoreHorizontal className="size-4" />
      <span className="sr-only">More pages</span>
    </span>
  );
}

export type PaginationRangeItem =
  | { type: "page"; page: number; selected: boolean }
  | { type: "ellipsis"; key: "start" | "end" };

export interface GetPaginationItemsOptions {
  siblingCount?: number;
  boundaryCount?: number;
}

export function getPaginationItems(
  page: number,
  totalPages: number,
  { siblingCount = 1, boundaryCount = 1 }: GetPaginationItemsOptions = {},
): PaginationRangeItem[] {
  if (totalPages <= 0) return [];

  const totalPageNumbers = siblingCount * 2 + 3 + boundaryCount * 2;

  if (totalPages <= totalPageNumbers) {
    return Array.from({ length: totalPages }, (_, index) => ({
      type: "page" as const,
      page: index + 1,
      selected: index + 1 === page,
    }));
  }

  const leftSiblingIndex = Math.max(page - siblingCount, boundaryCount + 2);
  const rightSiblingIndex = Math.min(page + siblingCount, totalPages - boundaryCount - 1);

  const showLeftEllipsis = leftSiblingIndex > boundaryCount + 2;
  const showRightEllipsis = rightSiblingIndex < totalPages - boundaryCount - 1;

  const items: PaginationRangeItem[] = [];

  for (let i = 1; i <= boundaryCount; i++) {
    items.push({ type: "page", page: i, selected: i === page });
  }

  if (showLeftEllipsis) {
    items.push({ type: "ellipsis", key: "start" });
  } else {
    for (let i = boundaryCount + 1; i < leftSiblingIndex; i++) {
      items.push({ type: "page", page: i, selected: i === page });
    }
  }

  for (let i = leftSiblingIndex; i <= rightSiblingIndex; i++) {
    items.push({ type: "page", page: i, selected: i === page });
  }

  if (showRightEllipsis) {
    items.push({ type: "ellipsis", key: "end" });
  } else {
    for (let i = rightSiblingIndex + 1; i <= totalPages - boundaryCount; i++) {
      items.push({ type: "page", page: i, selected: i === page });
    }
  }

  for (let i = totalPages - boundaryCount + 1; i <= totalPages; i++) {
    if (i > boundaryCount) {
      items.push({ type: "page", page: i, selected: i === page });
    }
  }

  return items;
}

export interface PaginationProps {
  page?: number;
  defaultPage?: number;
  totalPages: number;
  onPageChange?: (page: number) => void;
  siblingCount?: number;
  boundaryCount?: number;
  showFirstLast?: boolean;
  hideOnSinglePage?: boolean;
  showPrevNext?: boolean;
  disabled?: boolean;
  size?: "sm" | "md" | "lg" | "icon";
  className?: string;
  ariaLabel?: string;
  getHref?: (page: number) => string;
  previousLabel?: string;
  nextLabel?: string;
  firstLabel?: string;
  lastLabel?: string;
  showPageSummary?: boolean;
}

function navControlClassName(disabled: boolean) {
  return disabled ? "pointer-events-none opacity-50" : undefined;
}

function PaginationNavControl({
  label,
  ariaLabel,
  href,
  disabled,
  onNavigate,
  size,
  className,
  iconPosition = "start",
  children,
}: {
  label: string;
  ariaLabel: string;
  href?: string;
  disabled: boolean;
  onNavigate: () => void;
  size: "sm" | "md" | "lg" | "icon";
  className?: string;
  iconPosition?: "start" | "end";
  children: React.ReactNode;
}) {
  const sharedClassName = cn("gap-1 px-2.5", navControlClassName(disabled), className);
  const body =
    iconPosition === "start" ? (
      <>
        {children}
        <span>{label}</span>
      </>
    ) : (
      <>
        <span>{label}</span>
        {children}
      </>
    );

  if (href) {
    return (
      <PaginationLink
        href={href}
        aria-label={ariaLabel}
        aria-disabled={disabled || undefined}
        size={size}
        className={sharedClassName}
        onClick={(event) => {
          if (disabled) {
            event.preventDefault();
            return;
          }
          onNavigate();
        }}
      >
        {body}
      </PaginationLink>
    );
  }

  return (
    <PaginationButton
      aria-label={ariaLabel}
      aria-disabled={disabled || undefined}
      disabled={disabled}
      size={size}
      className={sharedClassName}
      onClick={onNavigate}
    >
      {body}
    </PaginationButton>
  );
}

export function Pagination({
  page: pageProp,
  defaultPage = 1,
  totalPages,
  onPageChange,
  siblingCount = 1,
  boundaryCount = 1,
  showFirstLast = false,
  hideOnSinglePage = false,
  showPrevNext = true,
  disabled = false,
  size = "icon",
  className,
  ariaLabel = "Pagination",
  getHref,
  previousLabel = "Previous",
  nextLabel = "Next",
  firstLabel = "First",
  lastLabel = "Last",
  showPageSummary = false,
}: PaginationProps) {
  const [page, setPage] = useControllableState({
    prop: pageProp,
    defaultProp: defaultPage,
    onChange: onPageChange,
  });

  const currentPage = Math.min(Math.max(page ?? defaultPage, 1), Math.max(totalPages, 1));

  if (hideOnSinglePage && totalPages <= 1) {
    return null;
  }

  const items = getPaginationItems(currentPage, totalPages, {
    siblingCount,
    boundaryCount,
  });
  const isFirstPage = currentPage <= 1;
  const isLastPage = currentPage >= totalPages;

  const goToPage = (nextPage: number) => {
    if (disabled) return;
    const clamped = Math.min(Math.max(nextPage, 1), Math.max(totalPages, 1));
    setPage(clamped);
  };

  const renderPageControl = (pageNumber: number, isActive: boolean) => {
    if (getHref) {
      return (
        <PaginationLink
          href={getHref(pageNumber)}
          isActive={isActive}
          size={size}
          aria-disabled={disabled || undefined}
          className={navControlClassName(disabled)}
          onClick={(event) => {
            if (disabled) {
              event.preventDefault();
              return;
            }
            goToPage(pageNumber);
          }}
        >
          {pageNumber}
        </PaginationLink>
      );
    }

    return (
      <PaginationButton
        isActive={isActive}
        size={size}
        disabled={disabled}
        onClick={() => goToPage(pageNumber)}
      >
        {pageNumber}
      </PaginationButton>
    );
  };

  return (
    <PaginationNav className={className} aria-label={ariaLabel}>
      {showPageSummary ? (
        <span className="sr-only" aria-live="polite">
          Page {currentPage} of {totalPages}
        </span>
      ) : null}
      <PaginationContent>
        {showFirstLast ? (
          <PaginationItem>
            <PaginationNavControl
              label={firstLabel}
              ariaLabel="Go to first page"
              href={getHref?.(1)}
              disabled={disabled || isFirstPage}
              onNavigate={() => goToPage(1)}
              size="sm"
            >
              <ChevronLeft className="size-4" aria-hidden="true" />
              <ChevronLeft className="-ml-2 size-4" aria-hidden="true" />
            </PaginationNavControl>
          </PaginationItem>
        ) : null}

        {showPrevNext ? (
          <PaginationItem>
            <PaginationNavControl
              label={previousLabel}
              ariaLabel="Go to previous page"
              href={getHref?.(currentPage - 1)}
              disabled={disabled || isFirstPage}
              onNavigate={() => goToPage(currentPage - 1)}
              size="sm"
            >
              <ChevronLeft className="size-4" aria-hidden="true" />
            </PaginationNavControl>
          </PaginationItem>
        ) : null}

        {items.map((item) => {
          if (item.type === "ellipsis") {
            return (
              <PaginationItem key={`ellipsis-${item.key}`}>
                <PaginationEllipsis />
              </PaginationItem>
            );
          }

          return (
            <PaginationItem key={`page-${item.page}`}>
              {renderPageControl(item.page, item.selected)}
            </PaginationItem>
          );
        })}

        {showPrevNext ? (
          <PaginationItem>
            <PaginationNavControl
              label={nextLabel}
              ariaLabel="Go to next page"
              href={getHref?.(currentPage + 1)}
              disabled={disabled || isLastPage}
              onNavigate={() => goToPage(currentPage + 1)}
              size="sm"
              iconPosition="end"
            >
              <ChevronRight className="size-4" aria-hidden="true" />
            </PaginationNavControl>
          </PaginationItem>
        ) : null}

        {showFirstLast ? (
          <PaginationItem>
            <PaginationNavControl
              label={lastLabel}
              ariaLabel="Go to last page"
              href={getHref?.(totalPages)}
              disabled={disabled || isLastPage}
              onNavigate={() => goToPage(totalPages)}
              size="sm"
              iconPosition="end"
            >
              <ChevronRight className="size-4" aria-hidden="true" />
              <ChevronRight className="-ml-2 size-4" aria-hidden="true" />
            </PaginationNavControl>
          </PaginationItem>
        ) : null}
      </PaginationContent>
    </PaginationNav>
  );
}

Related examples

Browse the full examples gallery