Web InnoventixFreeCode

Compact Pagination

Original · free

compact pagination with ellipsis

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

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 →