Compact Pagination
Original · freecompact pagination with ellipsis
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/page-compact.json"use client";
import { useId, useMemo, useState, type FormEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type PageToken = number | "start-ellipsis" | "end-ellipsis";
type Status = "Passed" | "Failed" | "Building";
type Deploy = {
id: number;
service: string;
message: string;
author: string;
status: Status;
duration: string;
ago: string;
};
function inclusiveRange(start: number, end: number): number[] {
const out: number[] = [];
for (let i = start; i <= end; i += 1) out.push(i);
return out;
}
function buildPagination(current: number, pageCount: number, siblings: number): PageToken[] {
const totalNumbers = siblings * 2 + 5;
if (totalNumbers >= pageCount) return inclusiveRange(1, pageCount);
const leftSibling = Math.max(current - siblings, 1);
const rightSibling = Math.min(current + siblings, pageCount);
const showLeftDots = leftSibling > 2;
const showRightDots = rightSibling < pageCount - 2;
if (!showLeftDots && showRightDots) {
const leftCount = 3 + siblings * 2;
return [...inclusiveRange(1, leftCount), "end-ellipsis", pageCount];
}
if (showLeftDots && !showRightDots) {
const rightCount = 3 + siblings * 2;
return [1, "start-ellipsis", ...inclusiveRange(pageCount - rightCount + 1, pageCount)];
}
return [
1,
"start-ellipsis",
...inclusiveRange(leftSibling, rightSibling),
"end-ellipsis",
pageCount,
];
}
function relativeAge(minutes: number): string {
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
const SERVICES = [
"api-gateway",
"web-frontend",
"auth-service",
"billing-worker",
"search-index",
"notification-svc",
"image-pipeline",
"analytics-etl",
];
const MESSAGES = [
"Fix N+1 query in invoice loader",
"Bump Node runtime to 20.11 LTS",
"Add exponential backoff to webhook sender",
"Cache tenant config for 60 seconds",
"Migrate sessions table to UUID v7",
"Patch CVE-2026-1841 in tar dependency",
"Enable HTTP/2 on edge nodes",
"Debounce autosave to 400ms",
"Roll back feature flag pricing_v3",
"Compress avatar uploads to WebP",
"Split checkout bundle by route",
"Add composite index on orders.created_at",
];
const AUTHORS = ["mara.k", "devon.p", "sana.q", "leo.t", "priya.n", "wei.chen", "tomas.b", "aisha.r"];
const STATUS_STYLES: Record<Status, string> = {
Passed:
"bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20",
Failed:
"bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/20",
Building:
"bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/20",
};
const DOT_STYLES: Record<Status, string> = {
Passed: "bg-emerald-500",
Failed: "bg-rose-500",
Building: "bg-amber-500 pgcompact-pulse",
};
const PAGE_SIZES = [8, 12, 20];
function ChevronLeft() {
return (
<svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
<path
d="M12.5 5 7.5 10l5 5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ChevronRight() {
return (
<svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
<path
d="m7.5 5 5 5-5 5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default function CompactPagination() {
const reduceMotion = useReducedMotion();
const uid = useId();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(8);
const [jump, setJump] = useState("");
const [jumpError, setJumpError] = useState(false);
const items = useMemo<Deploy[]>(() => {
const total = 94;
return Array.from({ length: total }, (_, i) => {
const status: Status = i % 11 === 3 ? "Failed" : i % 17 === 5 ? "Building" : "Passed";
return {
id: total - i,
service: SERVICES[i % SERVICES.length],
message: MESSAGES[(i * 7) % MESSAGES.length],
author: AUTHORS[(i * 5) % AUTHORS.length],
status,
duration: `${1 + (i % 6)}m ${((i * 13) % 60).toString().padStart(2, "0")}s`,
ago: relativeAge(i * 23 + 6),
};
});
}, []);
const total = items.length;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const current = Math.min(page, pageCount);
const tokens = useMemo(() => buildPagination(current, pageCount, 1), [current, pageCount]);
const start = (current - 1) * pageSize;
const slice = items.slice(start, start + pageSize);
const from = total === 0 ? 0 : start + 1;
const to = Math.min(start + pageSize, total);
const goTo = (target: number) => {
const next = Math.min(Math.max(1, Math.round(target)), pageCount);
setPage(next);
};
const changePageSize = (size: number) => {
setPageSize(size);
setPage(1);
};
const onJump = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const parsed = Number.parseInt(jump, 10);
if (Number.isNaN(parsed) || parsed < 1 || parsed > pageCount) {
setJumpError(true);
return;
}
goTo(parsed);
setJump("");
setJumpError(false);
};
const ellipsisTarget = (which: "start-ellipsis" | "end-ellipsis") =>
which === "start-ellipsis"
? Math.max(1, current - 5)
: Math.min(pageCount, current + 5);
const cellBase =
"inline-grid h-9 min-w-[2.25rem] place-items-center rounded-lg px-1 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";
const stepBase =
"inline-flex h-9 items-center gap-1 rounded-lg px-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 disabled:cursor-not-allowed disabled:opacity-40";
return (
<section className="relative w-full bg-slate-50 px-6 py-16 sm:py-20 dark:bg-slate-950">
<style>{`
@keyframes pgcompact-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.7); }
}
.pgcompact-pulse { animation: pgcompact-pulse 1.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.pgcompact-pulse { animation: none; }
}
`}</style>
<div className="mx-auto max-w-3xl">
<header className="mb-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Continuous delivery
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
Deployment history
</h2>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
{total} builds across {SERVICES.length} services this week. Jump anywhere without
scrolling a thousand rows.
</p>
</header>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
{/* Toolbar */}
<div className="flex flex-col gap-3 border-b border-slate-200 px-4 py-3 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800">
<p
className="text-sm text-slate-600 dark:text-slate-300"
aria-live="polite"
role="status"
>
Showing{" "}
<span className="font-semibold text-slate-900 dark:text-slate-100">{from}</span>
{"–"}
<span className="font-semibold text-slate-900 dark:text-slate-100">{to}</span> of{" "}
<span className="font-semibold text-slate-900 dark:text-slate-100">{total}</span>{" "}
deployments
</p>
<div className="flex items-center gap-2">
<label
htmlFor={`${uid}-size`}
className="text-sm text-slate-500 dark:text-slate-400"
>
Per page
</label>
<select
id={`${uid}-size`}
value={pageSize}
onChange={(event) => changePageSize(Number(event.target.value))}
className="h-9 rounded-lg border border-slate-300 bg-white px-2 text-sm font-medium text-slate-700 transition-colors hover:border-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-600 dark:focus-visible:ring-offset-slate-900"
>
{PAGE_SIZES.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
</div>
{/* List */}
<AnimatePresence mode="wait" initial={false}>
<motion.ul
key={`${current}-${pageSize}`}
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={reduceMotion ? undefined : { opacity: 1, y: 0 }}
exit={reduceMotion ? undefined : { opacity: 0, y: -8 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="divide-y divide-slate-100 dark:divide-slate-800"
>
{slice.map((item) => (
<li
key={item.id}
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50"
>
<span
className={`h-2.5 w-2.5 shrink-0 rounded-full ${DOT_STYLES[item.status]}`}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">
{item.message}
</p>
<p className="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">
<span className="font-mono text-slate-600 dark:text-slate-300">
#{item.id}
</span>{" "}
· {item.service} · {item.author} · {item.duration}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${STATUS_STYLES[item.status]}`}
>
{item.status}
</span>
<span className="text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
{item.ago}
</span>
</div>
</li>
))}
</motion.ul>
</AnimatePresence>
{/* Pagination footer */}
<div className="flex flex-col gap-4 border-t border-slate-200 px-4 py-3 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800">
<nav aria-label="Deployment history pages" className="flex items-center gap-1">
<button
type="button"
onClick={() => goTo(current - 1)}
disabled={current === 1}
className={`${stepBase} text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800`}
aria-label="Go to previous page"
>
<ChevronLeft />
<span className="hidden sm:inline">Prev</span>
</button>
<ul className="flex items-center gap-1">
{tokens.map((token) => {
if (token === "start-ellipsis" || token === "end-ellipsis") {
const target = ellipsisTarget(token);
return (
<li key={token}>
<button
type="button"
onClick={() => goTo(target)}
className={`${cellBase} group text-slate-400 hover:bg-slate-100 hover:text-slate-700 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-200`}
aria-label={`Jump to page ${target}`}
>
<span className="group-hover:hidden group-focus-visible:hidden" aria-hidden="true">
{"…"}
</span>
<span
className="hidden group-hover:inline group-focus-visible:inline"
aria-hidden="true"
>
{token === "start-ellipsis" ? "«" : "»"}
</span>
</button>
</li>
);
}
const isActive = token === current;
return (
<li key={token}>
<button
type="button"
onClick={() => goTo(token)}
aria-current={isActive ? "page" : undefined}
aria-label={`Go to page ${token}`}
className={`${cellBase} tabular-nums ${
isActive
? "bg-indigo-600 text-white shadow-sm hover:bg-indigo-600"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
}`}
>
{token}
</button>
</li>
);
})}
</ul>
<button
type="button"
onClick={() => goTo(current + 1)}
disabled={current === pageCount}
className={`${stepBase} text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800`}
aria-label="Go to next page"
>
<span className="hidden sm:inline">Next</span>
<ChevronRight />
</button>
</nav>
<form onSubmit={onJump} className="flex items-center gap-2">
<label
htmlFor={`${uid}-jump`}
className="text-sm text-slate-500 dark:text-slate-400"
>
Go to
</label>
<input
id={`${uid}-jump`}
type="number"
inputMode="numeric"
min={1}
max={pageCount}
value={jump}
onChange={(event) => {
setJump(event.target.value);
setJumpError(false);
}}
placeholder={String(current)}
aria-invalid={jumpError}
aria-describedby={jumpError ? `${uid}-jump-error` : undefined}
className={`h-9 w-16 rounded-lg border bg-white px-2 text-sm tabular-nums text-slate-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-800 dark:text-slate-200 dark:focus-visible:ring-offset-slate-900 ${
jumpError
? "border-rose-400 dark:border-rose-500"
: "border-slate-300 hover:border-slate-400 dark:border-slate-700 dark:hover:border-slate-600"
}`}
/>
<button
type="submit"
className="h-9 rounded-lg bg-slate-900 px-3 text-sm font-medium text-white transition-colors hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white dark:focus-visible:ring-offset-slate-900"
>
Go
</button>
{jumpError ? (
<span id={`${uid}-jump-error`} className="sr-only" role="alert">
Enter a page between 1 and {pageCount}.
</span>
) : null}
</form>
</div>
</div>
<p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
Page {current} of {pageCount} · hover an ellipsis to skip ahead five pages
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Pagination
Originalbasic numbered pagination

Rounded Pagination
Originalrounded pagination buttons

Arrows Pagination
Originalprev/next arrow pagination

Input Pagination
Originalpagination with a go-to-page input

Load More Pagination
Originalload-more button with progress

Dots Pagination
Originaldot pagination indicators

With Info Pagination
Originalpagination with results info

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

