Pagination
Controlled page navigation component.
| Prop | Type | Default |
|---|---|---|
| page | number | — |
| totalPages | number | — |
| onPageChange | (page: number) => void | — |
| color | "root" | "primary" | "root" |
<Pagination page={page} totalPages={10} onPageChange={setPage} />import { Pagination } from "@/ui";
<Pagination page={1} totalPages={10} onPageChange={setPage} />src/ui/Pagination.tsx
import { cn } from "@/utils";
import { IconButton } from "./IconButton";
import { ArrowBackIcon, ArrowForwardIcon } from "./icons";
interface PaginationProps {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
color?: "root" | "primary";
}
function getPageRange(page: number, totalPages: number): (number | "...")[] {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const pages: (number | "...")[] = [1];
if (page > 3) pages.push("...");
for (
let i = Math.max(2, page - 1);
i <= Math.min(totalPages - 1, page + 1);
i++
) {
pages.push(i);
}
if (page < totalPages - 2) pages.push("...");
pages.push(totalPages);
return pages;
}
export function Pagination({
page,
totalPages,
onPageChange,
color = "root",
}: PaginationProps) {
if (totalPages <= 1) return null;
const range = getPageRange(page, totalPages);
return (
<nav
aria-label="Pagination"
className="flex items-center justify-center gap-1"
>
<IconButton
variant="minimal"
color={color}
aria-label="Previous page"
onClick={() => onPageChange(page - 1)}
disabled={page === 1}
>
<ArrowBackIcon />
</IconButton>
{range.map((p, i) =>
p === "..." ? (
<span
key={`ellipsis-${i}`}
className="px-1 text-sm text-root-contrast-subtle"
>
…
</span>
) : (
<button
key={p}
onClick={() => onPageChange(p)}
aria-label={`Page ${p}`}
aria-current={p === page ? "page" : undefined}
className={cn(
"inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1",
p === page
? "pointer-events-none cursor-default bg-root-contrast font-semibold text-root"
: "text-root-contrast-subtle hover:bg-root-100 hover:text-root-contrast",
)}
>
{p}
</button>
),
)}
<IconButton
variant="minimal"
color={color}
aria-label="Next page"
onClick={() => onPageChange(page + 1)}
disabled={page === totalPages}
>
<ArrowForwardIcon />
</IconButton>
</nav>
);
}