Basic Pagination
Original · freebasic numbered pagination
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-basic.json"use client";
import { useCallback, useId, useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type ReleaseKind = "Added" | "Fixed" | "Changed" | "Security" | "Removed";
type Release = {
version: string;
date: string;
kind: ReleaseKind;
note: string;
};
const RELEASES: Release[] = [
{ version: "3.9.0", date: "2026-07-14", kind: "Added", note: "meridian sync --dry-run previews every remote change before it writes anything." },
{ version: "3.9.0", date: "2026-07-14", kind: "Fixed", note: "Watch mode no longer leaks file descriptors during long macOS sessions." },
{ version: "3.9.0", date: "2026-07-14", kind: "Changed", note: "Config resolution now prefers meridian.toml over shell environment variables." },
{ version: "3.8.2", date: "2026-07-02", kind: "Security", note: "Patched a path-traversal flaw in the import command's archive extractor." },
{ version: "3.8.2", date: "2026-07-02", kind: "Fixed", note: "Progress bars render correctly inside tmux panes narrower than 40 columns." },
{ version: "3.8.1", date: "2026-06-25", kind: "Fixed", note: "meridian login retries once on a dropped TLS handshake instead of failing outright." },
{ version: "3.8.0", date: "2026-06-18", kind: "Added", note: "New --json flag on every read command produces stable, scriptable output." },
{ version: "3.8.0", date: "2026-06-18", kind: "Added", note: "Shell completions for fish, including dynamically resolved remote names." },
{ version: "3.8.0", date: "2026-06-18", kind: "Changed", note: "Default cache directory moved to the XDG base-directory location." },
{ version: "3.7.4", date: "2026-06-09", kind: "Fixed", note: "Timestamps in the log view now respect the system's 12- or 24-hour locale." },
{ version: "3.7.3", date: "2026-05-30", kind: "Security", note: "Rotated the bundled root certificate set to the June CA release." },
{ version: "3.7.2", date: "2026-05-21", kind: "Fixed", note: "An empty .meridianignore file no longer matches every path in the tree." },
{ version: "3.7.0", date: "2026-05-12", kind: "Added", note: "meridian diff renders side-by-side output when the terminal is wide enough." },
{ version: "3.7.0", date: "2026-05-12", kind: "Removed", note: "Dropped the deprecated --legacy-auth flag; use the device flow instead." },
{ version: "3.6.1", date: "2026-05-01", kind: "Fixed", note: "Concurrent uploads now respect the --max-parallel limit under heavy load." },
{ version: "3.6.0", date: "2026-04-22", kind: "Added", note: "Resumable uploads survive network drops on files larger than 2 GB." },
{ version: "3.6.0", date: "2026-04-22", kind: "Changed", note: "Retry backoff is jittered to avoid thundering-herd reconnects after an outage." },
{ version: "3.5.3", date: "2026-04-10", kind: "Fixed", note: "meridian status no longer double-counts symlinked directories." },
{ version: "3.5.2", date: "2026-03-28", kind: "Security", note: "Tightened token-file permissions to 0600 on first write." },
{ version: "3.5.0", date: "2026-03-19", kind: "Added", note: "Plugin API v2 ships typed hooks for pre- and post-sync events." },
{ version: "3.5.0", date: "2026-03-19", kind: "Added", note: "meridian doctor diagnoses common auth and network misconfigurations." },
{ version: "3.4.1", date: "2026-03-06", kind: "Fixed", note: "Windows paths with trailing spaces are normalized before hashing." },
{ version: "3.4.0", date: "2026-02-25", kind: "Changed", note: "Minimum supported Node runtime raised to 20 LTS." },
{ version: "3.4.0", date: "2026-02-25", kind: "Added", note: "Colorized error output, with a NO_COLOR opt-out for plain terminals." },
{ version: "3.3.2", date: "2026-02-13", kind: "Fixed", note: "The spinner stops cleanly when a command is cancelled with Ctrl-C." },
{ version: "3.3.0", date: "2026-02-04", kind: "Added", note: "meridian watch batches rapid file events into a single sync pass." },
{ version: "3.3.0", date: "2026-02-04", kind: "Removed", note: "Retired the experimental HTTP/1 transport fallback." },
{ version: "3.2.1", date: "2026-01-23", kind: "Security", note: "Fixed a token leak in verbose logs when --debug was set." },
{ version: "3.2.0", date: "2026-01-14", kind: "Added", note: "Per-remote rate limits are now configurable in meridian.toml." },
{ version: "3.2.0", date: "2026-01-14", kind: "Changed", note: "init scaffolds a .meridianignore with sensible defaults." },
{ version: "3.1.2", date: "2026-01-03", kind: "Fixed", note: "Unicode filenames survive round-trips on case-insensitive volumes." },
{ version: "3.1.0", date: "2025-12-19", kind: "Added", note: "Offline mode queues changes and flushes them on reconnect." },
{ version: "3.1.0", date: "2025-12-19", kind: "Added", note: "meridian whoami prints the active account and token scope." },
{ version: "3.0.3", date: "2025-12-08", kind: "Fixed", note: "Large directory scans no longer block the event loop on Linux." },
{ version: "3.0.1", date: "2025-11-27", kind: "Security", note: "Signed release binaries with the new hardware-backed key." },
{ version: "3.0.0", date: "2025-11-18", kind: "Changed", note: "Rewrote the sync engine around content-addressed chunks." },
];
const KIND_STYLES: Record<ReleaseKind, string> = {
Added: "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25",
Fixed: "bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-400/25",
Changed: "bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/25",
Security: "bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/25",
Removed: "bg-zinc-100 text-zinc-600 ring-zinc-500/20 dark:bg-zinc-500/10 dark:text-zinc-300 dark:ring-zinc-400/25",
};
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function formatDate(iso: string): string {
const [y, m, d] = iso.split("-");
const month = MONTHS[Number(m) - 1] ?? m;
return `${Number(d)} ${month} ${y}`;
}
const PER_PAGE_OPTIONS = [4, 8, 12] as const;
function range(start: number, end: number): number[] {
const out: number[] = [];
for (let i = start; i <= end; i += 1) out.push(i);
return out;
}
function buildPages(page: number, total: number): (number | "gap")[] {
const siblings = 1;
const boundaries = 1;
const totalSlots = siblings * 2 + 3 + boundaries * 2;
if (totalSlots >= total) {
return range(1, total);
}
const leftSibling = Math.max(page - siblings, boundaries);
const rightSibling = Math.min(page + siblings, total - boundaries + 1);
const showLeftGap = leftSibling > boundaries + 2;
const showRightGap = rightSibling < total - (boundaries + 1);
if (!showLeftGap && showRightGap) {
const leftCount = siblings * 2 + boundaries + 2;
return [...range(1, leftCount), "gap", ...range(total - boundaries + 1, total)];
}
if (showLeftGap && !showRightGap) {
const rightCount = boundaries + 1 + siblings * 2;
return [...range(1, boundaries), "gap", ...range(total - rightCount + 1, total)];
}
return [
...range(1, boundaries),
"gap",
...range(leftSibling, rightSibling),
"gap",
...range(total - boundaries + 1, total),
];
}
function Chevron({ direction }: { direction: "left" | "right" }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
<path
d={direction === "left" ? "M12.5 4.5 7 10l5.5 5.5" : "M7.5 4.5 13 10l-5.5 5.5"}
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default function PageBasic() {
const reduce = useReducedMotion();
const uid = useId();
const headingId = `${uid}-heading`;
const regionId = `${uid}-region`;
const [perPage, setPerPage] = useState<number>(4);
const [rawPage, setRawPage] = useState<number>(1);
const [direction, setDirection] = useState<number>(0);
const total = RELEASES.length;
const totalPages = Math.max(1, Math.ceil(total / perPage));
const page = Math.min(Math.max(1, rawPage), totalPages);
const goTo = useCallback(
(next: number) => {
const clamped = Math.min(Math.max(1, next), totalPages);
setDirection(clamped > page ? 1 : clamped < page ? -1 : 0);
setRawPage(clamped);
},
[page, totalPages],
);
const changePerPage = useCallback((next: number) => {
setDirection(0);
setPerPage(next);
setRawPage(1);
}, []);
const start = (page - 1) * perPage;
const visible = useMemo(() => RELEASES.slice(start, start + perPage), [start, perPage]);
const pages = useMemo(() => buildPages(page, totalPages), [page, totalPages]);
const firstShown = total === 0 ? 0 : start + 1;
const lastShown = Math.min(start + perPage, total);
const listVariants = {
enter: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * 26 }),
center: { opacity: 1, x: 0 },
exit: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * -26 }),
};
const atStart = page <= 1;
const atEnd = page >= totalPages;
return (
<section
aria-labelledby={headingId}
className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 sm:py-24 dark:bg-slate-950 dark:text-slate-100"
>
<style>{`
@keyframes pagebasic-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
@keyframes pagebasic-fade {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.pagebasic-live { animation: pagebasic-pulse 2.4s ease-in-out infinite; }
.pagebasic-intro { animation: pagebasic-fade 0.5s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.pagebasic-live, .pagebasic-intro { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-64 bg-[radial-gradient(60%_100%_at_50%_0%,rgba(99,102,241,0.14),transparent_70%)] dark:bg-[radial-gradient(60%_100%_at_50%_0%,rgba(99,102,241,0.22),transparent_70%)]"
/>
<div className="relative mx-auto max-w-2xl">
<header className="pagebasic-intro mb-8">
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium text-slate-600 backdrop-blur dark:border-slate-800 dark:bg-slate-900/70 dark:text-slate-300">
<span className="relative flex h-2 w-2">
<span className="pagebasic-live absolute inline-flex h-full w-full rounded-full bg-indigo-500" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-indigo-500" />
</span>
Meridian CLI · Changelog
</div>
<h2 id={headingId} className="text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
Every release, in order
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{total} shipped changes across the 3.x line. Page through the log below — versions run newest first.
</p>
</header>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/20">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-3.5 dark:border-slate-800">
<p id={regionId} role="status" aria-live="polite" className="text-sm text-slate-600 dark:text-slate-400">
Showing{" "}
<span className="font-semibold text-slate-900 tabular-nums dark:text-white">
{firstShown}–{lastShown}
</span>{" "}
of <span className="font-semibold text-slate-900 tabular-nums dark:text-white">{total}</span>
</p>
<div
role="group"
aria-label="Results per page"
className="flex items-center gap-1 rounded-lg bg-slate-100 p-1 dark:bg-slate-800"
>
{PER_PAGE_OPTIONS.map((n) => {
const active = perPage === n;
return (
<button
key={n}
type="button"
aria-pressed={active}
onClick={() => changePerPage(n)}
className={
"rounded-md px-2.5 py-1 text-xs font-semibold tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-800 " +
(active
? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200")
}
>
{n}
</button>
);
})}
</div>
</div>
<div role="region" aria-labelledby={headingId} aria-describedby={regionId}>
<AnimatePresence mode="wait" custom={direction} initial={false}>
<motion.ul
key={`${page}-${perPage}`}
custom={direction}
variants={listVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: reduce ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] }}
className="divide-y divide-slate-100 dark:divide-slate-800/80"
>
{visible.map((item, i) => (
<li key={`${item.version}-${start + i}`} className="flex items-start gap-4 px-5 py-4">
<span
className={
"mt-0.5 inline-flex shrink-0 items-center rounded-md px-2 py-0.5 text-xs font-semibold ring-1 ring-inset " +
KIND_STYLES[item.kind]
}
>
{item.kind}
</span>
<div className="min-w-0">
<p className="text-sm leading-relaxed text-slate-800 dark:text-slate-200">{item.note}</p>
<p className="mt-1 flex items-center gap-2 text-xs text-slate-400 dark:text-slate-500">
<span className="font-mono font-medium text-slate-500 dark:text-slate-400">v{item.version}</span>
<span aria-hidden="true">·</span>
<time dateTime={item.date}>{formatDate(item.date)}</time>
</p>
</div>
</li>
))}
</motion.ul>
</AnimatePresence>
</div>
<nav
aria-label="Pagination"
className="flex items-center justify-between gap-2 border-t border-slate-200 px-4 py-3 dark:border-slate-800"
>
<button
type="button"
onClick={() => goTo(page - 1)}
disabled={atStart}
aria-label="Go to previous page"
className="inline-flex h-10 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:pointer-events-none disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
<Chevron direction="left" />
<span className="hidden sm:inline">Prev</span>
</button>
<ul className="flex items-center gap-1">
{pages.map((p, i) =>
p === "gap" ? (
<li key={`gap-${i}`} aria-hidden="true" className="flex h-10 w-10 items-center justify-center text-slate-400 dark:text-slate-600">
…
</li>
) : (
<li key={p}>
<button
type="button"
onClick={() => goTo(p)}
aria-label={`Go to page ${p}`}
aria-current={p === page ? "page" : undefined}
className={
"relative inline-flex h-10 min-w-10 items-center justify-center rounded-lg px-3 text-sm font-medium tabular-nums 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 " +
(p === page
? "text-white"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800")
}
>
{p === page && (
<motion.span
layoutId={`${uid}-active-pill`}
className="absolute inset-0 rounded-lg bg-indigo-600 shadow-sm shadow-indigo-600/30 dark:bg-indigo-500"
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 42 }}
/>
)}
<span className="relative z-10">{p}</span>
</button>
</li>
),
)}
</ul>
<button
type="button"
onClick={() => goTo(page + 1)}
disabled={atEnd}
aria-label="Go to next page"
className="inline-flex h-10 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:pointer-events-none disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
<span className="hidden sm:inline">Next</span>
<Chevron direction="right" />
</button>
</nav>
</div>
<p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-600">
Page <span className="tabular-nums">{page}</span> of <span className="tabular-nums">{totalPages}</span>
</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 →
Rounded Pagination
Originalrounded pagination buttons

Arrows Pagination
Originalprev/next arrow pagination

Compact Pagination
Originalcompact pagination with ellipsis

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.

