Pagination
Page numbers with smart ellipses and a sliding active pill.
Showing results 41–50 of 200
components/ui/pagination.tsx
"use client";
import { motion } from "motion/react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useId, useState } from "react";
/** Returns page numbers with "…" gaps, e.g. [1, "…", 4, 5, 6, "…", 20] */
export function pageRange(current: number, total: number, siblings = 1): (number | "…")[] {
const range: (number | "…")[] = [];
const start = Math.max(2, current - siblings);
const end = Math.min(total - 1, current + siblings);
range.push(1);
if (start > 2) range.push("…");
for (let i = start; i <= end; i++) range.push(i);
if (end < total - 1) range.push("…");
if (total > 1) range.push(total);
return range;
}
export function Pagination({ page, total, onChange }: { page: number; total: number; onChange: (page: number) => void }) {
const pillId = useId();
const btn = "grid h-9 min-w-9 place-items-center rounded-lg px-2 text-sm transition-colors disabled:pointer-events-none disabled:opacity-40";
return (
<nav aria-label="Pagination" className="flex items-center gap-1">
<button className={`${btn} text-zinc-600 hover:bg-zinc-100`} onClick={() => onChange(page - 1)} disabled={page === 1} aria-label="Previous page">
<ChevronLeft className="size-4" />
</button>
{pageRange(page, total).map((p, i) =>
p === "…" ? (
<span key={`gap-${i}`} className="px-1 text-sm text-zinc-400">
…
</span>
) : (
<button key={p} onClick={() => onChange(p)} aria-current={p === page ? "page" : undefined} className={`${btn} relative ${p === page ? "text-white" : "text-zinc-600 hover:bg-zinc-100"}`}>
{p === page && <motion.span layoutId={pillId} className="absolute inset-0 rounded-lg bg-zinc-900" transition={{ type: "spring", stiffness: 450, damping: 34 }} />}
<span className="relative tabular-nums">{p}</span>
</button>
),
)}
<button className={`${btn} text-zinc-600 hover:bg-zinc-100`} onClick={() => onChange(page + 1)} disabled={page === total} aria-label="Next page">
<ChevronRight className="size-4" />
</button>
</nav>
);
}
export default function Demo() {
const [page, setPage] = useState(5);
return (
<div className="flex flex-col items-center gap-3">
<Pagination page={page} total={20} onChange={setPage} />
<p className="text-sm text-zinc-500">Showing results {(page - 1) * 10 + 1}–{page * 10} of 200</p>
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/pagination.tsxcomponents/ui/pagination.tsx"use client"; import { motion } from "motion/react"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { useId, useState } from "react"; /** Returns page numbers with "…" gaps, e.g. [1, "…", 4, 5, 6, "…", 20] */ export function pageRange(current: number, total: number, siblings = 1): (number | "…")[] { const range: (number | "…")[] = []; const start = Math.max(2, current - siblings); const end = Math.min(total - 1, current + siblings); range.push(1); if (start > 2) range.push("…"); for (let i = start; i <= end; i++) range.push(i); if (end < total - 1) range.push("…"); if (total > 1) range.push(total); return range; } export function Pagination({ page, total, onChange }: { page: number; total: number; onChange: (page: number) => void }) { const pillId = useId(); const btn = "grid h-9 min-w-9 place-items-center rounded-lg px-2 text-sm transition-colors disabled:pointer-events-none disabled:opacity-40"; return ( <nav aria-label="Pagination" className="flex items-center gap-1"> <button className={`${btn} text-zinc-600 hover:bg-zinc-100`} onClick={() => onChange(page - 1)} disabled={page === 1} aria-label="Previous page"> <ChevronLeft className="size-4" /> </button> {pageRange(page, total).map((p, i) => p === "…" ? ( <span key={`gap-${i}`} className="px-1 text-sm text-zinc-400"> … </span> ) : ( <button key={p} onClick={() => onChange(p)} aria-current={p === page ? "page" : undefined} className={`${btn} relative ${p === page ? "text-white" : "text-zinc-600 hover:bg-zinc-100"}`}> {p === page && <motion.span layoutId={pillId} className="absolute inset-0 rounded-lg bg-zinc-900" transition={{ type: "spring", stiffness: 450, damping: 34 }} />} <span className="relative tabular-nums">{p}</span> </button> ), )} <button className={`${btn} text-zinc-600 hover:bg-zinc-100`} onClick={() => onChange(page + 1)} disabled={page === total} aria-label="Next page"> <ChevronRight className="size-4" /> </button> </nav> ); } export default function Demo() { const [page, setPage] = useState(5); return ( <div className="flex flex-col items-center gap-3"> <Pagination page={page} total={20} onChange={setPage} /> <p className="text-sm text-zinc-500">Showing results {(page - 1) * 10 + 1}–{page * 10} of 200</p> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.