Web InnoventixFreeCode

Basic Pagination

Original · free

basic numbered pagination

byWeb InnoventixReact + Tailwind
pagebasicpagination
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-basic.json
page-basic.tsx
"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

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 →