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.jsonLive 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
| Prop | Type | Default | Description |
|---|---|---|---|
| page / defaultPage | number | — | Current page (1-indexed). |
| totalPages* | number | — | Total number of pages. |
| onPageChange | (page: number) => void | — | Called when the user selects a new page. |
| getHref | (page: number) => string | — | When set, renders anchor links instead of buttons. |
| siblingCount | number | 1 | Pages shown on each side of the current page. |
| boundaryCount | number | 1 | Pages always shown at the start and end. |
| showFirstLast | boolean | false | Show jump-to-first/last controls. |
| hideOnSinglePage | boolean | false | Render nothing when `totalPages` is 1. |
| isActive (PaginationLink) | boolean | — | Marks the current page with `aria-current="page"`. |
| size | "sm" | "md" | "lg" | "icon" | icon | Control 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
Page linksPrevious, numbered pages, ellipsis, and next.
ControlledClient-side page state with disabled bounds.