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 siblingsEnd = Math.min(
Math.max(page + siblingCount, boundaryCount + siblingCount * 2 + 2),
endPages.length > 0 ? endPages[0] - 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