Rounded Pagination
Original · freerounded pagination buttons
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-rounded.json"use client";
import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
type Category =
| "Engineering"
| "Design"
| "Performance"
| "Accessibility"
| "Product";
type Article = {
id: number;
title: string;
category: Category;
date: string;
readMins: number;
};
type PageToken = number | "start-ellipsis" | "end-ellipsis";
const CATEGORY_STYLES: Record<Category, string> = {
Engineering:
"bg-indigo-100 text-indigo-700 ring-indigo-600/20 dark:bg-indigo-500/15 dark:text-indigo-300 dark:ring-indigo-400/25",
Design:
"bg-violet-100 text-violet-700 ring-violet-600/20 dark:bg-violet-500/15 dark:text-violet-300 dark:ring-violet-400/25",
Performance:
"bg-amber-100 text-amber-800 ring-amber-600/20 dark:bg-amber-500/15 dark:text-amber-300 dark:ring-amber-400/25",
Accessibility:
"bg-emerald-100 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/15 dark:text-emerald-300 dark:ring-emerald-400/25",
Product:
"bg-sky-100 text-sky-700 ring-sky-600/20 dark:bg-sky-500/15 dark:text-sky-300 dark:ring-sky-400/25",
};
const ARTICLES: Article[] = [
{ id: 1, title: "Migrating a 40k-line React app from Webpack to Vite", category: "Engineering", date: "Jun 28, 2026", readMins: 9 },
{ id: 2, title: "What a single unoptimized hero image really costs you", category: "Performance", date: "Jun 24, 2026", readMins: 6 },
{ id: 3, title: "Building a keyboard-first command palette from scratch", category: "Accessibility", date: "Jun 20, 2026", readMins: 11 },
{ id: 4, title: "Designing focus states nobody has to think about", category: "Design", date: "Jun 15, 2026", readMins: 7 },
{ id: 5, title: "Why we deleted 60% of our onboarding flow", category: "Product", date: "Jun 11, 2026", readMins: 5 },
{ id: 6, title: "Type-safe forms without a validation library", category: "Engineering", date: "Jun 6, 2026", readMins: 10 },
{ id: 7, title: "Shipping under 100ms interaction latency on cheap phones", category: "Performance", date: "Jun 2, 2026", readMins: 8 },
{ id: 8, title: "A color system that survives dark mode", category: "Design", date: "May 28, 2026", readMins: 6 },
{ id: 9, title: "Screen reader testing: the workflow we actually use", category: "Accessibility", date: "May 23, 2026", readMins: 9 },
{ id: 10, title: "Streaming server components without the foot-guns", category: "Engineering", date: "May 19, 2026", readMins: 12 },
{ id: 11, title: "Pricing pages that answer the question before it's asked", category: "Product", date: "May 14, 2026", readMins: 7 },
{ id: 12, title: "Cutting our JavaScript bundle in half in one afternoon", category: "Performance", date: "May 9, 2026", readMins: 6 },
{ id: 13, title: "Motion that guides instead of decorates", category: "Design", date: "May 5, 2026", readMins: 8 },
{ id: 14, title: "Postgres row-level security for multi-tenant SaaS", category: "Engineering", date: "Apr 30, 2026", readMins: 13 },
{ id: 15, title: "Accessible charts that don't rely on color alone", category: "Accessibility", date: "Apr 25, 2026", readMins: 10 },
{ id: 16, title: "The changelog is a feature, not an afterthought", category: "Product", date: "Apr 21, 2026", readMins: 4 },
{ id: 17, title: "Debugging a memory leak we ignored for six months", category: "Engineering", date: "Apr 16, 2026", readMins: 11 },
{ id: 18, title: "Lazy-loading images without a single layout shift", category: "Performance", date: "Apr 12, 2026", readMins: 5 },
{ id: 19, title: "Empty states worth designing on purpose", category: "Design", date: "Apr 7, 2026", readMins: 6 },
{ id: 20, title: "Making a custom select that respects the platform", category: "Accessibility", date: "Apr 2, 2026", readMins: 9 },
{ id: 21, title: "Feature flags without a third-party service", category: "Engineering", date: "Mar 29, 2026", readMins: 8 },
{ id: 22, title: "Reading 1,200 support tickets so you don't have to", category: "Product", date: "Mar 24, 2026", readMins: 7 },
{ id: 23, title: "The database index that fixed our slowest page", category: "Performance", date: "Mar 20, 2026", readMins: 6 },
{ id: 24, title: "Typography scales that read well on every screen", category: "Design", date: "Mar 15, 2026", readMins: 7 },
{ id: 25, title: "Writing tests that survive a refactor", category: "Engineering", date: "Mar 11, 2026", readMins: 10 },
{ id: 26, title: "Focus traps done right in modals and drawers", category: "Accessibility", date: "Mar 6, 2026", readMins: 8 },
{ id: 27, title: "Sunsetting a beloved feature with grace", category: "Product", date: "Mar 2, 2026", readMins: 5 },
{ id: 28, title: "Caching strategies that don't betray your users", category: "Performance", date: "Feb 25, 2026", readMins: 9 },
{ id: 29, title: "A pragmatic guide to React error boundaries", category: "Engineering", date: "Feb 20, 2026", readMins: 7 },
{ id: 30, title: "Icons that stay legible at 16 pixels", category: "Design", date: "Feb 15, 2026", readMins: 6 },
{ id: 31, title: "Announcing async updates with aria-live regions", category: "Accessibility", date: "Feb 10, 2026", readMins: 8 },
{ id: 32, title: "The metrics we stopped tracking and never missed", category: "Product", date: "Feb 5, 2026", readMins: 6 },
];
const PER_PAGE = 4;
const range = (start: number, end: number): number[] =>
start > end ? [] : Array.from({ length: end - start + 1 }, (_, i) => start + i);
function buildPages(
page: number,
count: number,
boundaryCount = 1,
siblingCount = 1,
): PageToken[] {
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 startGap: PageToken[] =
siblingsStart > boundaryCount + 2
? ["start-ellipsis"]
: boundaryCount + 1 < count - boundaryCount
? [boundaryCount + 1]
: [];
const endGap: PageToken[] =
siblingsEnd < count - boundaryCount - 1
? ["end-ellipsis"]
: count - boundaryCount > boundaryCount
? [count - boundaryCount]
: [];
return [
...startPages,
...startGap,
...range(siblingsStart, siblingsEnd),
...endGap,
...endPages,
];
}
function ChevronLeft() {
return (
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className="h-4 w-4"
>
<path
d="M12.5 15L7.5 10L12.5 5"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ChevronRight() {
return (
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className="h-4 w-4"
>
<path
d="M7.5 5L12.5 10L7.5 15"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default function PageRounded() {
const reduce = useReducedMotion();
const [page, setPage] = useState(1);
const totalPages = Math.ceil(ARTICLES.length / PER_PAGE);
const pages = useMemo(() => buildPages(page, totalPages), [page, totalPages]);
const current = useMemo(() => {
const start = (page - 1) * PER_PAGE;
return ARTICLES.slice(start, start + PER_PAGE);
}, [page]);
const rangeStart = (page - 1) * PER_PAGE + 1;
const rangeEnd = Math.min(page * PER_PAGE, ARTICLES.length);
const activeBtnRef = useRef<HTMLButtonElement | null>(null);
const shouldFocusRef = useRef<boolean>(false);
useEffect(() => {
if (shouldFocusRef.current) {
activeBtnRef.current?.focus();
shouldFocusRef.current = false;
}
}, [page]);
function goTo(next: number) {
const clamped = Math.min(Math.max(next, 1), totalPages);
if (clamped !== page) setPage(clamped);
}
function goByKeyboard(next: number) {
const clamped = Math.min(Math.max(next, 1), totalPages);
if (clamped !== page) {
shouldFocusRef.current = true;
setPage(clamped);
}
}
function onNavKeyDown(event: KeyboardEvent<HTMLElement>) {
switch (event.key) {
case "ArrowRight":
event.preventDefault();
goByKeyboard(page + 1);
break;
case "ArrowLeft":
event.preventDefault();
goByKeyboard(page - 1);
break;
case "Home":
event.preventDefault();
goByKeyboard(1);
break;
case "End":
event.preventDefault();
goByKeyboard(totalPages);
break;
default:
break;
}
}
const springTransition = reduce
? { duration: 0 }
: { type: "spring" as const, stiffness: 520, damping: 40 };
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
<style>{`
.pgr-sheen-bar {
background-size: 200% 100%;
animation: pgr-sheen 9s linear infinite;
}
@keyframes pgr-sheen {
0% { background-position: 0% 50%; }
100% { background-position: -200% 50%; }
}
.pgr-live-dot {
animation: pgr-pulse 2.4s ease-in-out infinite;
}
@keyframes pgr-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.6); }
}
@media (prefers-reduced-motion: reduce) {
.pgr-sheen-bar, .pgr-live-dot { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 h-64 w-[42rem] max-w-full -translate-x-1/2 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-600/15"
/>
<div className="relative mx-auto max-w-3xl">
<header className="mb-8">
<div className="flex items-center gap-3">
<span className="grid h-11 w-11 place-items-center rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-lg shadow-indigo-500/25">
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="h-5 w-5"
>
<path
d="M4 5.5A1.5 1.5 0 0 1 5.5 4H11v16H5.5A1.5 1.5 0 0 1 4 18.5v-13Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path
d="M20 5.5A1.5 1.5 0 0 0 18.5 4H13v16h5.5a1.5 1.5 0 0 0 1.5-1.5v-13Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
</span>
<div>
<h2 className="text-xl font-semibold tracking-tight sm:text-2xl">
The Frontend Journal
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Field notes on building fast, accessible interfaces.
</p>
</div>
</div>
<div className="pgr-sheen-bar mt-5 h-px w-full rounded-full bg-gradient-to-r from-transparent via-indigo-400 to-transparent dark:via-violet-500" />
</header>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-slate-500 dark:text-slate-400">
Showing{" "}
<span className="font-semibold text-slate-800 dark:text-slate-200">
{rangeStart}–{rangeEnd}
</span>{" "}
of{" "}
<span className="font-semibold text-slate-800 dark:text-slate-200">
{ARTICLES.length}
</span>{" "}
articles
</p>
<span className="inline-flex items-center gap-2 rounded-full bg-white px-3 py-1 text-xs font-medium text-slate-600 ring-1 ring-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:ring-slate-800">
<span className="pgr-live-dot h-1.5 w-1.5 rounded-full bg-emerald-500" />
Updated weekly
</span>
</div>
<ul key={page} className="space-y-3">
{current.map((article, i) => (
<motion.li
key={article.id}
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.32, delay: reduce ? 0 : i * 0.05 }}
>
<article className="group flex items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 transition-colors hover:border-indigo-300 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-indigo-500/40">
<div className="min-w-0 flex-1">
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset ${CATEGORY_STYLES[article.category]}`}
>
{article.category}
</span>
<h3 className="mt-2 truncate text-base font-semibold text-slate-900 dark:text-slate-100">
{article.title}
</h3>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{article.date} · {article.readMins} min read
</p>
</div>
<span
aria-hidden="true"
className="grid h-9 w-9 shrink-0 place-items-center rounded-full text-slate-400 transition-all group-hover:bg-indigo-50 group-hover:text-indigo-600 dark:text-slate-500 dark:group-hover:bg-indigo-500/10 dark:group-hover:text-indigo-300"
>
<ChevronRight />
</span>
</article>
</motion.li>
))}
</ul>
<nav
aria-label="Journal pagination"
onKeyDown={onNavKeyDown}
className="mt-8 flex items-center justify-center gap-1.5 sm:gap-2"
>
<button
type="button"
onClick={() => goTo(page - 1)}
disabled={page === 1}
aria-label="Go to previous page"
className="inline-flex h-9 items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 text-sm font-medium text-slate-700 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-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white sm:h-10 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-950 dark:disabled:hover:bg-slate-900"
>
<ChevronLeft />
<span className="hidden sm:inline">Prev</span>
</button>
<ul className="flex items-center gap-1.5 sm:gap-2">
{pages.map((token, index) => {
if (token === "start-ellipsis" || token === "end-ellipsis") {
return (
<li key={`${token}-${index}`} aria-hidden="true">
<span className="grid h-9 w-9 place-items-center text-slate-400 sm:h-10 sm:w-10 dark:text-slate-600">
<svg viewBox="0 0 24 8" className="h-2 w-5" aria-hidden="true">
<circle cx="4" cy="4" r="1.6" fill="currentColor" />
<circle cx="12" cy="4" r="1.6" fill="currentColor" />
<circle cx="20" cy="4" r="1.6" fill="currentColor" />
</svg>
</span>
</li>
);
}
const isActive = token === page;
return (
<li key={token}>
<button
type="button"
ref={isActive ? activeBtnRef : undefined}
onClick={() => goTo(token)}
aria-current={isActive ? "page" : undefined}
aria-label={
isActive
? `Page ${token}, current page`
: `Go to page ${token}`
}
className={`relative grid h-9 w-9 place-items-center rounded-full text-sm font-semibold 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-50 sm:h-10 sm:w-10 dark:focus-visible:ring-offset-slate-950 ${
isActive
? "text-white"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
}`}
>
{isActive && (
<motion.span
aria-hidden="true"
layoutId={reduce ? undefined : "pgr-active-pill"}
transition={springTransition}
className="absolute inset-0 rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 shadow-lg shadow-indigo-500/30"
/>
)}
<span className="relative z-10">{token}</span>
</button>
</li>
);
})}
</ul>
<button
type="button"
onClick={() => goTo(page + 1)}
disabled={page === totalPages}
aria-label="Go to next page"
className="inline-flex h-9 items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 text-sm font-medium text-slate-700 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-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white sm:h-10 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-950 dark:disabled:hover:bg-slate-900"
>
<span className="hidden sm:inline">Next</span>
<ChevronRight />
</button>
</nav>
<p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
Tip: focus a page and use the arrow, Home, and End keys to navigate.
</p>
<div aria-live="polite" className="sr-only">
{`Page ${page} of ${totalPages}. Showing articles ${rangeStart} to ${rangeEnd} of ${ARTICLES.length}.`}
</div>
</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

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.

