Pagination Table
Original · freetable with footer pagination
byWeb InnoventixReact + Tailwind
tablepaginationtables
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.jsontable-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 quoteSimilar components
Browse all →
Basic Table
Originalclean basic data table

Striped Table
Originalstriped-row table

Bordered Table
Originalbordered table

Hover Table
Originalrow-hover highlight table

Sortable Table
Originalsortable-column table

Selectable Table
Originaltable with row checkboxes and select-all

Sticky Header Table
Originalsticky header scroll table

Search Table
Originaltable with a search filter

Expandable Table
Originaltable with expandable rows

Actions Table
Originaltable with row action menus

Status Badges Table
Originaltable with status badges

Compact Table
Originaldense compact table

