Web InnoventixFreeCode

Rounded Pagination

Original · free

rounded pagination buttons

byWeb InnoventixReact + Tailwind
pageroundedpagination
Open

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
page-rounded.tsx
"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}&ndash;{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} &middot; {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

motion

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 quote

Similar components

Browse all →