Web InnoventixFreeCode

Pagination Table

Original · free

table with footer pagination

byWeb InnoventixReact + Tailwind
tablepaginationtables
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/table-pagination.json
table-pagination.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

type DeployStatus = "success" | "failed" | "running";
type SortKey = "service" | "environment" | "duration" | "status" | "deployed";
type SortDir = "asc" | "desc";
type StatusFilter = "all" | DeployStatus;

interface Deployment {
  id: string;
  service: string;
  environment: "Production" | "Staging" | "Preview";
  commit: string;
  author: string;
  durationSec: number;
  status: DeployStatus;
  minutesAgo: number;
}

const DATA: Deployment[] = [
  { id: "dep-01", service: "checkout-api", environment: "Production", commit: "a1f4c92", author: "Priya Nair", durationSec: 132, status: "success", minutesAgo: 3 },
  { id: "dep-02", service: "auth-gateway", environment: "Production", commit: "7d0b3e1", author: "Marcus Feld", durationSec: 88, status: "success", minutesAgo: 14 },
  { id: "dep-03", service: "billing-worker", environment: "Staging", commit: "c4e9a20", author: "Dana Ortiz", durationSec: 245, status: "failed", minutesAgo: 26 },
  { id: "dep-04", service: "search-indexer", environment: "Production", commit: "9b2f7c5", author: "Kenji Watanabe", durationSec: 512, status: "running", minutesAgo: 1 },
  { id: "dep-05", service: "notify-service", environment: "Preview", commit: "3ac81d4", author: "Lena Brandt", durationSec: 41, status: "success", minutesAgo: 47 },
  { id: "dep-06", service: "web-storefront", environment: "Production", commit: "e6d2b90", author: "Omar Haddad", durationSec: 176, status: "success", minutesAgo: 62 },
  { id: "dep-07", service: "image-resizer", environment: "Staging", commit: "5f1c8a3", author: "Sofia Rossi", durationSec: 63, status: "success", minutesAgo: 91 },
  { id: "dep-08", service: "recommendation-engine", environment: "Production", commit: "2b7e4f6", author: "Tom Becker", durationSec: 388, status: "failed", minutesAgo: 120 },
  { id: "dep-09", service: "payments-ledger", environment: "Production", commit: "8c3a1d9", author: "Priya Nair", durationSec: 209, status: "success", minutesAgo: 133 },
  { id: "dep-10", service: "analytics-pipeline", environment: "Staging", commit: "4e9b2c7", author: "Marcus Feld", durationSec: 671, status: "running", minutesAgo: 5 },
  { id: "dep-11", service: "inventory-sync", environment: "Production", commit: "f1a6d38", author: "Dana Ortiz", durationSec: 154, status: "success", minutesAgo: 152 },
  { id: "dep-12", service: "email-render", environment: "Preview", commit: "7c2e9b0", author: "Kenji Watanabe", durationSec: 29, status: "success", minutesAgo: 168 },
  { id: "dep-13", service: "fraud-scanner", environment: "Production", commit: "b3d5f81", author: "Lena Brandt", durationSec: 297, status: "failed", minutesAgo: 181 },
  { id: "dep-14", service: "media-encoder", environment: "Staging", commit: "6a0c4e2", author: "Omar Haddad", durationSec: 733, status: "success", minutesAgo: 203 },
  { id: "dep-15", service: "session-store", environment: "Production", commit: "d9f3a17", author: "Sofia Rossi", durationSec: 74, status: "success", minutesAgo: 219 },
  { id: "dep-16", service: "pricing-engine", environment: "Production", commit: "1e8b6c4", author: "Tom Becker", durationSec: 188, status: "running", minutesAgo: 8 },
  { id: "dep-17", service: "webhook-relay", environment: "Preview", commit: "a5c2f90", author: "Priya Nair", durationSec: 52, status: "success", minutesAgo: 244 },
  { id: "dep-18", service: "cart-service", environment: "Production", commit: "3f7d1b8", author: "Marcus Feld", durationSec: 143, status: "success", minutesAgo: 260 },
  { id: "dep-19", service: "tax-calculator", environment: "Staging", commit: "9c4e2a6", author: "Dana Ortiz", durationSec: 118, status: "failed", minutesAgo: 275 },
  { id: "dep-20", service: "shipping-quotes", environment: "Production", commit: "5b8f3d1", author: "Kenji Watanabe", durationSec: 96, status: "success", minutesAgo: 289 },
  { id: "dep-21", service: "profile-api", environment: "Production", commit: "e2a7c94", author: "Lena Brandt", durationSec: 167, status: "success", minutesAgo: 312 },
  { id: "dep-22", service: "loyalty-points", environment: "Staging", commit: "8d1b5f3", author: "Omar Haddad", durationSec: 84, status: "success", minutesAgo: 331 },
  { id: "dep-23", service: "gateway-proxy", environment: "Production", commit: "4a9c2e7", author: "Sofia Rossi", durationSec: 221, status: "running", minutesAgo: 12 },
  { id: "dep-24", service: "catalog-search", environment: "Production", commit: "c7f4b18", author: "Tom Becker", durationSec: 349, status: "success", minutesAgo: 355 },
  { id: "dep-25", service: "review-moderation", environment: "Preview", commit: "2e6d9a5", author: "Priya Nair", durationSec: 47, status: "failed", minutesAgo: 372 },
  { id: "dep-26", service: "pdf-generator", environment: "Staging", commit: "f8b3c21", author: "Marcus Feld", durationSec: 158, status: "success", minutesAgo: 390 },
  { id: "dep-27", service: "currency-feed", environment: "Production", commit: "6d2a8f4", author: "Dana Ortiz", durationSec: 38, status: "success", minutesAgo: 405 },
  { id: "dep-28", service: "warehouse-api", environment: "Production", commit: "b1e7c93", author: "Kenji Watanabe", durationSec: 264, status: "success", minutesAgo: 421 },
  { id: "dep-29", service: "sms-dispatch", environment: "Preview", commit: "9f3b6d2", author: "Lena Brandt", durationSec: 55, status: "running", minutesAgo: 19 },
  { id: "dep-30", service: "audit-logger", environment: "Staging", commit: "3c8e1a7", author: "Omar Haddad", durationSec: 129, status: "success", minutesAgo: 448 },
  { id: "dep-31", service: "config-service", environment: "Production", commit: "7b4f2c9", author: "Sofia Rossi", durationSec: 71, status: "failed", minutesAgo: 462 },
  { id: "dep-32", service: "feature-flags", environment: "Production", commit: "e5a1d86", author: "Tom Becker", durationSec: 43, status: "success", minutesAgo: 480 },
  { id: "dep-33", service: "backup-runner", environment: "Staging", commit: "1d9c4b3", author: "Priya Nair", durationSec: 902, status: "success", minutesAgo: 505 },
  { id: "dep-34", service: "cdn-purger", environment: "Production", commit: "8a2e7f5", author: "Marcus Feld", durationSec: 61, status: "success", minutesAgo: 523 },
];

const PAGE_SIZES = [5, 10, 20] as const;

const STATUS_FILTERS: { value: StatusFilter; label: string }[] = [
  { value: "all", label: "All" },
  { value: "success", label: "Passed" },
  { value: "failed", label: "Failed" },
  { value: "running", label: "Running" },
];

function compareBy(a: Deployment, b: Deployment, key: SortKey): number {
  switch (key) {
    case "service":
      return a.service.localeCompare(b.service);
    case "environment":
      return a.environment.localeCompare(b.environment);
    case "duration":
      return a.durationSec - b.durationSec;
    case "status":
      return a.status.localeCompare(b.status);
    case "deployed":
      return a.minutesAgo - b.minutesAgo;
    default:
      return 0;
  }
}

function formatDuration(sec: number): string {
  if (sec < 60) return `${sec}s`;
  const m = Math.floor(sec / 60);
  const s = sec % 60;
  return s === 0 ? `${m}m` : `${m}m ${s}s`;
}

function formatAgo(min: number): string {
  if (min < 60) return `${min}m ago`;
  if (min < 1440) return `${Math.floor(min / 60)}h ago`;
  return `${Math.floor(min / 1440)}d ago`;
}

function pageRange(current: number, total: number): (number | "gap")[] {
  const out: (number | "gap")[] = [];
  const left = Math.max(2, current - 1);
  const right = Math.min(total - 1, current + 1);
  out.push(1);
  if (left > 2) out.push("gap");
  for (let i = left; i <= right; i++) out.push(i);
  if (right < total - 1) out.push("gap");
  if (total > 1) out.push(total);
  return out;
}

const FOCUS_RING =
  "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";

function SortCaret({ active, dir }: { active: boolean; dir: SortDir }) {
  return (
    <svg
      viewBox="0 0 16 16"
      aria-hidden="true"
      className={`h-3.5 w-3.5 shrink-0 transition-opacity ${active ? "opacity-100" : "opacity-30 group-hover:opacity-60"}`}
    >
      {active ? (
        <path
          d={dir === "asc" ? "M8 4l4 5H4z" : "M8 12L4 7h8z"}
          className="fill-indigo-600 dark:fill-indigo-400"
        />
      ) : (
        <>
          <path d="M8 3l3 3.5H5z" className="fill-slate-400 dark:fill-slate-500" />
          <path d="M8 13l3-3.5H5z" className="fill-slate-400 dark:fill-slate-500" />
        </>
      )}
    </svg>
  );
}

function StatusBadge({ status }: { status: DeployStatus }) {
  if (status === "success") {
    return (
      <span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20">
        <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
        Passed
      </span>
    );
  }
  if (status === "failed") {
    return (
      <span className="inline-flex items-center gap-1.5 rounded-full bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700 ring-1 ring-inset ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/20">
        <span className="h-1.5 w-1.5 rounded-full bg-rose-500" />
        Failed
      </span>
    );
  }
  return (
    <span className="inline-flex items-center gap-1.5 rounded-full bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/20">
      <span className="relative flex h-1.5 w-1.5">
        <span className="tblpg-ping-dot absolute inline-flex h-full w-full rounded-full bg-amber-400" />
        <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-amber-500" />
      </span>
      Running
    </span>
  );
}

function EnvBadge({ env }: { env: Deployment["environment"] }) {
  const tone =
    env === "Production"
      ? "bg-violet-50 text-violet-700 ring-violet-600/20 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-400/20"
      : env === "Staging"
        ? "bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-400/20"
        : "bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-slate-700/40 dark:text-slate-300 dark:ring-slate-400/20";
  return (
    <span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${tone}`}>
      {env}
    </span>
  );
}

export default function TablePagination() {
  const reduce = useReducedMotion();

  const [search, setSearch] = useState("");
  const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
  const [sortKey, setSortKey] = useState<SortKey>("deployed");
  const [sortDir, setSortDir] = useState<SortDir>("asc");
  const [pageSize, setPageSize] = useState<number>(5);
  const [page, setPage] = useState(1);
  const [selected, setSelected] = useState<Set<string>>(() => new Set());

  const selectAllRef = useRef<HTMLInputElement>(null);

  const filtered = useMemo(() => {
    const q = search.trim().toLowerCase();
    return DATA.filter((row) => {
      if (statusFilter !== "all" && row.status !== statusFilter) return false;
      if (!q) return true;
      return (
        row.service.toLowerCase().includes(q) ||
        row.author.toLowerCase().includes(q) ||
        row.commit.toLowerCase().includes(q)
      );
    });
  }, [search, statusFilter]);

  const sorted = useMemo(() => {
    const factor = sortDir === "asc" ? 1 : -1;
    return [...filtered].sort((a, b) => factor * compareBy(a, b, sortKey));
  }, [filtered, sortKey, sortDir]);

  const total = sorted.length;
  const pageCount = Math.max(1, Math.ceil(total / pageSize));
  const safePage = Math.min(Math.max(1, page), pageCount);
  const start = (safePage - 1) * pageSize;
  const visible = sorted.slice(start, start + pageSize);
  const rangeStart = total === 0 ? 0 : start + 1;
  const rangeEnd = Math.min(start + pageSize, total);

  const visibleIds = visible.map((r) => r.id);
  const allOnPageSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
  const someOnPageSelected = visibleIds.some((id) => selected.has(id));

  useEffect(() => {
    if (selectAllRef.current) {
      selectAllRef.current.indeterminate = someOnPageSelected && !allOnPageSelected;
    }
  }, [someOnPageSelected, allOnPageSelected]);

  function toggleSort(key: SortKey) {
    if (sortKey === key) {
      setSortDir((d) => (d === "asc" ? "desc" : "asc"));
    } else {
      setSortKey(key);
      setSortDir("asc");
    }
  }

  function ariaSort(key: SortKey): "ascending" | "descending" | "none" {
    if (sortKey !== key) return "none";
    return sortDir === "asc" ? "ascending" : "descending";
  }

  function toggleRow(id: string) {
    setSelected((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }

  function toggleAllOnPage() {
    setSelected((prev) => {
      const next = new Set(prev);
      if (allOnPageSelected) {
        visibleIds.forEach((id) => next.delete(id));
      } else {
        visibleIds.forEach((id) => next.add(id));
      }
      return next;
    });
  }

  const headStyle =
    "px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400";

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-24">
      <style>{`
        @keyframes tblpg-ping {
          0% { transform: scale(1); opacity: 0.7; }
          75%, 100% { transform: scale(2.4); opacity: 0; }
        }
        .tblpg-ping-dot { animation: tblpg-ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite; }
        @media (prefers-reduced-motion: reduce) {
          .tblpg-ping-dot { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <header className="mb-6 flex flex-col gap-1">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Delivery
          </p>
          <h2 className="text-2xl font-bold tracking-tight sm:text-3xl">Deployment history</h2>
          <p className="max-w-prose text-sm text-slate-500 dark:text-slate-400">
            Every push to the fleet, sortable and paginated. Filter by pipeline result or search a
            service, author, or commit.
          </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 p-4 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
            <div className="relative w-full sm:max-w-xs">
              <span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-slate-400">
                <svg viewBox="0 0 20 20" className="h-4 w-4" aria-hidden="true" fill="none">
                  <circle cx="9" cy="9" r="6" stroke="currentColor" strokeWidth="1.6" />
                  <path d="M14 14l3.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
                </svg>
              </span>
              <label htmlFor="tblpg-search" className="sr-only">
                Search deployments
              </label>
              <input
                id="tblpg-search"
                type="search"
                value={search}
                onChange={(e) => {
                  setSearch(e.target.value);
                  setPage(1);
                }}
                placeholder="Search service, author, commit…"
                className={`w-full rounded-lg border border-slate-300 bg-white py-2 pl-9 pr-3 text-sm text-slate-900 placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500 ${FOCUS_RING}`}
              />
            </div>

            <div
              className="flex flex-wrap items-center gap-1 rounded-lg bg-slate-100 p-1 dark:bg-slate-800"
              role="group"
              aria-label="Filter by status"
            >
              {STATUS_FILTERS.map((f) => {
                const active = statusFilter === f.value;
                return (
                  <button
                    key={f.value}
                    type="button"
                    aria-pressed={active}
                    onClick={() => {
                      setStatusFilter(f.value);
                      setPage(1);
                    }}
                    className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${FOCUS_RING} ${
                      active
                        ? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
                        : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
                    }`}
                  >
                    {f.label}
                  </button>
                );
              })}
            </div>
          </div>

          {/* Table */}
          <div className="overflow-x-auto">
            <table className="w-full border-collapse text-sm">
              <caption className="sr-only">
                Deployment history, sortable by column, {total} results.
              </caption>
              <thead>
                <tr className="border-b border-slate-200 bg-slate-50/60 dark:border-slate-800 dark:bg-slate-800/40">
                  <th scope="col" className="w-12 px-4 py-3">
                    <input
                      ref={selectAllRef}
                      type="checkbox"
                      checked={allOnPageSelected}
                      onChange={toggleAllOnPage}
                      aria-label="Select all deployments on this page"
                      className={`h-4 w-4 cursor-pointer rounded border-slate-300 text-indigo-600 accent-indigo-600 dark:border-slate-600 ${FOCUS_RING}`}
                    />
                  </th>

                  <th scope="col" aria-sort={ariaSort("service")} className={headStyle}>
                    <button
                      type="button"
                      onClick={() => toggleSort("service")}
                      className={`group inline-flex items-center gap-1.5 rounded ${FOCUS_RING}`}
                    >
                      Service
                      <SortCaret active={sortKey === "service"} dir={sortDir} />
                    </button>
                  </th>

                  <th scope="col" aria-sort={ariaSort("environment")} className={headStyle}>
                    <button
                      type="button"
                      onClick={() => toggleSort("environment")}
                      className={`group inline-flex items-center gap-1.5 rounded ${FOCUS_RING}`}
                    >
                      Environment
                      <SortCaret active={sortKey === "environment"} dir={sortDir} />
                    </button>
                  </th>

                  <th scope="col" className={`${headStyle} hidden md:table-cell`}>
                    Commit
                  </th>

                  <th scope="col" aria-sort={ariaSort("duration")} className={`${headStyle} text-right`}>
                    <button
                      type="button"
                      onClick={() => toggleSort("duration")}
                      className={`group inline-flex items-center gap-1.5 rounded ${FOCUS_RING}`}
                    >
                      Duration
                      <SortCaret active={sortKey === "duration"} dir={sortDir} />
                    </button>
                  </th>

                  <th scope="col" aria-sort={ariaSort("status")} className={headStyle}>
                    <button
                      type="button"
                      onClick={() => toggleSort("status")}
                      className={`group inline-flex items-center gap-1.5 rounded ${FOCUS_RING}`}
                    >
                      Status
                      <SortCaret active={sortKey === "status"} dir={sortDir} />
                    </button>
                  </th>

                  <th scope="col" aria-sort={ariaSort("deployed")} className={`${headStyle} text-right`}>
                    <button
                      type="button"
                      onClick={() => toggleSort("deployed")}
                      className={`group inline-flex items-center gap-1.5 rounded ${FOCUS_RING}`}
                    >
                      Deployed
                      <SortCaret active={sortKey === "deployed"} dir={sortDir} />
                    </button>
                  </th>
                </tr>
              </thead>

              <tbody>
                {visible.length === 0 ? (
                  <tr>
                    <td colSpan={7} className="px-4 py-16 text-center">
                      <p className="text-sm font-medium text-slate-600 dark:text-slate-300">
                        No deployments match your filters
                      </p>
                      <p className="mt-1 text-sm text-slate-400 dark:text-slate-500">
                        Try a different search term or clear the status filter.
                      </p>
                    </td>
                  </tr>
                ) : (
                  visible.map((row, i) => {
                    const isSelected = selected.has(row.id);
                    return (
                      <motion.tr
                        key={row.id}
                        initial={reduce ? false : { opacity: 0, y: 4 }}
                        animate={{ opacity: 1, y: 0 }}
                        transition={{ duration: 0.2, delay: reduce ? 0 : i * 0.02 }}
                        className={`border-b border-slate-100 transition-colors last:border-0 dark:border-slate-800/70 ${
                          isSelected
                            ? "bg-indigo-50/70 dark:bg-indigo-500/10"
                            : "hover:bg-slate-50 dark:hover:bg-slate-800/40"
                        }`}
                      >
                        <td className="px-4 py-3">
                          <input
                            type="checkbox"
                            checked={isSelected}
                            onChange={() => toggleRow(row.id)}
                            aria-label={`Select deployment of ${row.service}`}
                            className={`h-4 w-4 cursor-pointer rounded border-slate-300 text-indigo-600 accent-indigo-600 dark:border-slate-600 ${FOCUS_RING}`}
                          />
                        </td>
                        <td className="px-4 py-3">
                          <div className="font-medium text-slate-900 dark:text-slate-100">{row.service}</div>
                          <div className="text-xs text-slate-400 dark:text-slate-500">{row.author}</div>
                        </td>
                        <td className="px-4 py-3">
                          <EnvBadge env={row.environment} />
                        </td>
                        <td className="hidden px-4 py-3 md:table-cell">
                          <code className="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">
                            {row.commit}
                          </code>
                        </td>
                        <td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-slate-600 dark:text-slate-300">
                          {formatDuration(row.durationSec)}
                        </td>
                        <td className="px-4 py-3">
                          <StatusBadge status={row.status} />
                        </td>
                        <td className="px-4 py-3 text-right text-slate-500 tabular-nums dark:text-slate-400">
                          {formatAgo(row.minutesAgo)}
                        </td>
                      </motion.tr>
                    );
                  })
                )}
              </tbody>
            </table>
          </div>

          {/* Footer pagination */}
          <div className="flex flex-col gap-4 border-t border-slate-200 p-4 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
            <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-5">
              <p aria-live="polite" className="text-sm text-slate-500 dark:text-slate-400">
                {total === 0 ? (
                  "No results"
                ) : (
                  <>
                    Showing{" "}
                    <span className="font-medium text-slate-700 dark:text-slate-200">{rangeStart}</span>–
                    <span className="font-medium text-slate-700 dark:text-slate-200">{rangeEnd}</span> of{" "}
                    <span className="font-medium text-slate-700 dark:text-slate-200">{total}</span>
                  </>
                )}
                {selected.size > 0 && (
                  <span className="ml-2 text-indigo-600 dark:text-indigo-400">· {selected.size} selected</span>
                )}
              </p>

              <div className="flex items-center gap-2">
                <label htmlFor="tblpg-size" className="text-sm text-slate-500 dark:text-slate-400">
                  Rows
                </label>
                <select
                  id="tblpg-size"
                  value={pageSize}
                  onChange={(e) => {
                    setPageSize(Number(e.target.value));
                    setPage(1);
                  }}
                  className={`cursor-pointer rounded-lg border border-slate-300 bg-white py-1.5 pl-2.5 pr-8 text-sm text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 ${FOCUS_RING}`}
                >
                  {PAGE_SIZES.map((size) => (
                    <option key={size} value={size}>
                      {size}
                    </option>
                  ))}
                </select>
              </div>
            </div>

            <nav aria-label="Pagination" className="flex items-center gap-1">
              <button
                type="button"
                onClick={() => setPage((p) => Math.max(1, p - 1))}
                disabled={safePage <= 1}
                aria-label="Previous page"
                className={`inline-flex h-9 items-center gap-1 rounded-lg border border-slate-300 bg-white px-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700 dark:disabled:hover:bg-slate-800 ${FOCUS_RING}`}
              >
                <svg viewBox="0 0 20 20" className="h-4 w-4" aria-hidden="true" fill="none">
                  <path d="M12 5l-5 5 5 5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
                <span className="hidden sm:inline">Prev</span>
              </button>

              <div className="flex items-center gap-1">
                {pageRange(safePage, pageCount).map((item, idx) =>
                  item === "gap" ? (
                    <span
                      key={`gap-${idx}`}
                      className="inline-flex h-9 w-9 items-center justify-center text-sm text-slate-400 dark:text-slate-600"
                      aria-hidden="true"
                    >
                      …
                    </span>
                  ) : (
                    <button
                      key={item}
                      type="button"
                      onClick={() => setPage(item)}
                      aria-label={`Page ${item}`}
                      aria-current={item === safePage ? "page" : undefined}
                      className={`inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium tabular-nums transition-colors ${FOCUS_RING} ${
                        item === safePage
                          ? "bg-indigo-600 text-white shadow-sm"
                          : "border border-slate-300 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
                      }`}
                    >
                      {item}
                    </button>
                  ),
                )}
              </div>

              <button
                type="button"
                onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
                disabled={safePage >= pageCount}
                aria-label="Next page"
                className={`inline-flex h-9 items-center gap-1 rounded-lg border border-slate-300 bg-white px-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700 dark:disabled:hover:bg-slate-800 ${FOCUS_RING}`}
              >
                <span className="hidden sm:inline">Next</span>
                <svg viewBox="0 0 20 20" className="h-4 w-4" aria-hidden="true" fill="none">
                  <path d="M8 5l5 5-5 5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </button>
            </nav>
          </div>
        </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 →