Pagination
Navigation across paginated content with truncation for long ranges.
import { useState } from "react";
import { Pagination, usePagination } from "@/components/pagination";
export default function PaginationPreview() {
const [page, setPage] = useState(1);
const count = 10;
const items = usePagination({ page, count });
return (
<Pagination>
<Pagination.List>
<Pagination.Item>
<Pagination.Previous
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
/>
</Pagination.Item>
{items.map((item) =>
item.type === "page" ? (
<Pagination.Item key={item.key}>
<Pagination.Link isActive={item.selected} onClick={() => setPage(item.value)}>
{item.value}
</Pagination.Link>
</Pagination.Item>
) : (
<Pagination.Item key={item.key}>
<Pagination.Ellipsis />
</Pagination.Item>
),
)}
<Pagination.Item>
<Pagination.Next
onClick={() => setPage((p) => Math.min(count, p + 1))}
disabled={page === count}
/>
</Pagination.Item>
</Pagination.List>
</Pagination>
);
}Dependencies
Source Code
import { CaretLeftIcon, CaretRightIcon, DotsThreeIcon } from "@phosphor-icons/react/dist/ssr";
import { useMemo } from "react";
import { Slot, Slottable } from "@/components/slot";
import { cn, cva } from "@/lib/utils/classnames";
const paginationLinkStyle = cva({
base: [
"inline-flex h-(--pagination-height) min-w-(--pagination-height) shrink-0 items-center justify-center px-2",
"rounded-lg font-medium text-foreground-secondary text-sm",
"transition enabled:cursor-pointer disabled:opacity-40",
"hover:bg-foreground/5 hover:text-foreground",
"focus-visible:ring-(length:--ring-width) outline-none ring-ring",
"data-selected:bg-accent data-selected:text-accent-foreground data-selected:hover:bg-accent/90",
],
variants: {
size: {
sm: "[--pagination-height:--spacing(8)]",
md: "[--pagination-height:--spacing(10)]",
},
},
defaultVariants: {
size: "sm",
},
});
const Pagination = ({ ref, className, ...props }: React.ComponentPropsWithRef<"nav">) => {
return (
<nav
ref={ref}
aria-label="pagination"
className={cn("flex w-full justify-center", className)}
{...props}
/>
);
};
const PaginationList = ({ ref, className, ...props }: React.ComponentPropsWithRef<"ul">) => {
return <ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />;
};
const PaginationItem = ({ ref, ...props }: React.ComponentPropsWithRef<"li">) => {
return <li ref={ref} {...props} />;
};
interface PaginationLinkProps extends React.ComponentPropsWithRef<"button"> {
asChild?: boolean;
isActive?: boolean;
size?: "sm" | "md";
}
const PaginationLink = ({
ref,
className,
asChild,
isActive,
size = "sm",
type = "button",
...props
}: PaginationLinkProps) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
type={asChild ? undefined : type}
aria-current={isActive ? "page" : undefined}
data-selected={isActive || undefined}
className={cn(paginationLinkStyle({ size }), className)}
{...props}
/>
);
};
type PaginationStepProps = Omit<PaginationLinkProps, "children"> & {
children?: React.ReactElement;
};
const PaginationPrevious = ({ asChild, children, ...props }: PaginationStepProps) => {
return (
<PaginationLink asChild={asChild} aria-label="Go to previous page" {...props}>
<Slottable asChild={asChild ?? false} child={children}>
{(child) => (
<>
<CaretLeftIcon />
{child}
</>
)}
</Slottable>
</PaginationLink>
);
};
const PaginationNext = ({ asChild, children, ...props }: PaginationStepProps) => {
return (
<PaginationLink asChild={asChild} aria-label="Go to next page" {...props}>
<Slottable asChild={asChild ?? false} child={children}>
{(child) => (
<>
{child}
<CaretRightIcon />
</>
)}
</Slottable>
</PaginationLink>
);
};
const PaginationEllipsis = ({ className, ...props }: React.ComponentPropsWithRef<"span">) => {
return (
<span
aria-hidden="true"
className={cn(
"flex h-9 w-9 items-center justify-center text-foreground-secondary",
className,
)}
{...props}
>
<DotsThreeIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
};
const CompoundPagination = Object.assign(Pagination, {
List: PaginationList,
Item: PaginationItem,
Link: PaginationLink,
Previous: PaginationPrevious,
Next: PaginationNext,
Ellipsis: PaginationEllipsis,
});
export { CompoundPagination as Pagination };
interface UsePaginationOptions {
/** Current 1-indexed page. */
page: number;
/** Total number of pages. */
count: number;
/** Pages shown on either side of the current page. */
siblingCount?: number;
/** Pages always shown at the start and end. */
boundaryCount?: number;
}
export type PaginationItemDescriptor =
| { type: "page"; value: number; selected: boolean; key: string }
| { type: "ellipsis"; key: "start-ellipsis" | "end-ellipsis" };
const range = (start: number, end: number): number[] => {
const length = Math.max(0, end - start + 1);
return Array.from({ length }, (_, i) => start + i);
};
/**
* Returns the visible page sequence for a paginator with truncation.
*/
export const usePagination = ({
page,
count,
siblingCount = 1,
boundaryCount = 1,
}: UsePaginationOptions): PaginationItemDescriptor[] => {
return useMemo(() => {
if (count <= 0) return [];
const startPages = range(1, Math.min(boundaryCount, count));
const endPages = range(Math.max(count - boundaryCount + 1, boundaryCount + 1), count);
const siblingsStart = Math.max(
Math.min(page - siblingCount, count - boundaryCount - siblingCount * 2 - 1),
boundaryCount + 2,
);
const firstEndPage = endPages[0];
const siblingsEnd = Math.min(
Math.max(page + siblingCount, boundaryCount + siblingCount * 2 + 2),
firstEndPage !== undefined ? firstEndPage - 2 : count - 1,
);
const items: PaginationItemDescriptor[] = [];
for (const value of startPages) {
items.push({
type: "page",
value,
selected: value === page,
key: `page-${value}`,
});
}
if (siblingsStart > boundaryCount + 2) {
items.push({ type: "ellipsis", key: "start-ellipsis" });
} else if (boundaryCount + 1 < count - boundaryCount) {
items.push({
type: "page",
value: boundaryCount + 1,
selected: page === boundaryCount + 1,
key: `page-${boundaryCount + 1}`,
});
}
for (const value of range(siblingsStart, siblingsEnd)) {
items.push({
type: "page",
value,
selected: value === page,
key: `page-${value}`,
});
}
if (siblingsEnd < count - boundaryCount - 1) {
items.push({ type: "ellipsis", key: "end-ellipsis" });
} else if (count - boundaryCount > boundaryCount) {
items.push({
type: "page",
value: count - boundaryCount,
selected: page === count - boundaryCount,
key: `page-${count - boundaryCount}`,
});
}
for (const value of endPages) {
items.push({
type: "page",
value,
selected: value === page,
key: `page-${value}`,
});
}
return items;
}, [page, count, siblingCount, boundaryCount]);
};Anatomy
<Pagination>
<Pagination.List>
<Pagination.Item>
<Pagination.Previous />
</Pagination.Item>
<Pagination.Item>
<Pagination.Link />
</Pagination.Item>
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
<Pagination.Item>
<Pagination.Next />
</Pagination.Item>
</Pagination.List>
</Pagination>
The compound parts are purely structural and styled. Pagination state (current page, total pages) lives with the consumer — wire it up to URL params, local state, or a router. The optional usePagination hook handles the truncation algorithm so you don’t reinvent it.
API Reference
Pagination
Extends the nav element. Renders with aria-label="pagination".
Pagination.List
Extends the ul element.
Pagination.Item
Extends the li element.
Pagination.Link
Extends the button element. Use asChild to render as an <a> (or framework Link) for URL-driven pagination.
| Prop | Default | Type | Description |
|---|---|---|---|
isActive | - | boolean | Marks the link as the current page. Adds aria-current='page' and a selected style. |
size | "sm" | "sm""md" | |
asChild | - | boolean |
Pagination.Previous
A Pagination.Link preset that renders a left chevron with aria-label="Go to previous page". Icon-only by design — disable it (or omit href when used asChild) at the first page.
Pagination.Next
A Pagination.Link preset that renders a right chevron with aria-label="Go to next page". Icon-only by design.
Pagination.Ellipsis
A decorative gap between page ranges. Rendered with aria-hidden="true" and an sr-only “More pages” label.
usePagination hook
Computes the visible page sequence with truncation. Pass the current page and total count; it returns an array of items you can map over.
import { usePagination } from "@/foundations/ui/pagination/pagination";
const items = usePagination({
page: 5,
count: 20,
siblingCount: 1, // pages on each side of `page`
boundaryCount: 1, // pages always visible at the start/end
});
// items: [
// { type: 'page', value: 1, selected: false, key: 'page-1' },
// { type: 'ellipsis', key: 'start-ellipsis' },
// { type: 'page', value: 4, selected: false, key: 'page-4' },
// { type: 'page', value: 5, selected: true, key: 'page-5' },
// { type: 'page', value: 6, selected: false, key: 'page-6' },
// { type: 'ellipsis', key: 'end-ellipsis' },
// { type: 'page', value: 20, selected: false, key: 'page-20' },
// ]
The hook returns a stable key for each item so React reconciles correctly across page changes.
Examples
Simple range
For small page counts, skip the hook and map over the range directly.
import { useState } from "react";
import { Pagination } from "@/components/pagination";
export default function PaginationSimplePreview() {
const [page, setPage] = useState(1);
const count = 5;
return (
<Pagination>
<Pagination.List>
<Pagination.Item>
<Pagination.Previous
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
/>
</Pagination.Item>
{Array.from({ length: count }, (_, i) => i + 1).map((value) => (
<Pagination.Item key={value}>
<Pagination.Link isActive={value === page} onClick={() => setPage(value)}>
{value}
</Pagination.Link>
</Pagination.Item>
))}
<Pagination.Item>
<Pagination.Next
onClick={() => setPage((p) => Math.min(count, p + 1))}
disabled={page === count}
/>
</Pagination.Item>
</Pagination.List>
</Pagination>
);
}URL-driven with asChild
Wrap the Pagination.Link, Pagination.Previous, and Pagination.Next around your routing library’s link component.
import { Pagination, usePagination } from "@/components/pagination";
export default function PaginationLinkPreview() {
// In a real app, derive `page` from the URL (search params or route).
const page = 3;
const count = 8;
const items = usePagination({ page, count });
const hrefFor = (p: number) => `?page=${p}`;
return (
<Pagination>
<Pagination.List>
<Pagination.Item>
<Pagination.Previous asChild>
<a href={hrefFor(Math.max(1, page - 1))}>
<span className="sr-only">Previous</span>
</a>
</Pagination.Previous>
</Pagination.Item>
{items.map((item) =>
item.type === "page" ? (
<Pagination.Item key={item.key}>
<Pagination.Link asChild isActive={item.selected}>
<a href={hrefFor(item.value)}>{item.value}</a>
</Pagination.Link>
</Pagination.Item>
) : (
<Pagination.Item key={item.key}>
<Pagination.Ellipsis />
</Pagination.Item>
),
)}
<Pagination.Item>
<Pagination.Next asChild>
<a href={hrefFor(Math.min(count, page + 1))}>
<span className="sr-only">Next</span>
</a>
</Pagination.Next>
</Pagination.Item>
</Pagination.List>
</Pagination>
);
}Best Practices
-
State:
- Pagination is almost always controlled. Source the current page from the URL when possible — it makes results shareable and survives refresh.
usePaginationis optional. For ≤7 pages, mapping a fixed range is clearer.
-
Accessibility:
- The active page must have
aria-current="page"(set automatically whenisActiveis true). - Disable
Pagination.Previous/Pagination.Nextat the boundaries — don’t just hide them. - Ellipses are decorative; never put a focusable element inside them.
- The active page must have
-
Routing:
- Use
asChildto compose withnext/linkor any router-aware component. Pass the destinationhrefto the inner element so prefetching and link semantics work as expected.
- Use
Previous
OTP Input
Next
Popover