Bordered Table
Original · freebordered table
byWeb InnoventixReact + Tailwind
tableborderedtables
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-bordered.jsontable-bordered.tsx
"use client";
import { useId, useMemo, useState } from "react";
import { motion, useReducedMotion, type Variants } from "motion/react";
type ProviderName = "AWS" | "GCP" | "Azure";
type SortKey = "region" | "provider" | "latency" | "uptime" | "cost";
type SortDir = "asc" | "desc";
type Region = {
id: string;
region: string;
city: string;
provider: ProviderName;
latency: number;
uptime: number;
cost: number;
};
type Column = {
key: SortKey;
label: string;
numeric: boolean;
hint: string;
};
const REGIONS: Region[] = [
{ id: "aws-use1", region: "us-east-1", city: "N. Virginia", provider: "AWS", latency: 24, uptime: 99.99, cost: 412 },
{ id: "aws-euw2", region: "eu-west-2", city: "London", provider: "AWS", latency: 38, uptime: 99.98, cost: 458 },
{ id: "aws-apse1", region: "ap-southeast-1", city: "Singapore", provider: "AWS", latency: 71, uptime: 99.97, cost: 503 },
{ id: "gcp-usc1", region: "us-central1", city: "Iowa", provider: "GCP", latency: 29, uptime: 99.99, cost: 389 },
{ id: "gcp-euw4", region: "europe-west4", city: "Netherlands", provider: "GCP", latency: 41, uptime: 99.96, cost: 431 },
{ id: "gcp-ase1", region: "asia-east1", city: "Taiwan", provider: "GCP", latency: 68, uptime: 99.95, cost: 486 },
{ id: "az-eus2", region: "eastus2", city: "Virginia", provider: "Azure", latency: 26, uptime: 99.99, cost: 423 },
{ id: "az-weu", region: "westeurope", city: "Amsterdam", provider: "Azure", latency: 44, uptime: 99.97, cost: 447 },
];
const COLUMNS: Column[] = [
{ key: "region", label: "Region", numeric: false, hint: "region identifier" },
{ key: "provider", label: "Provider", numeric: false, hint: "cloud provider" },
{ key: "latency", label: "Latency", numeric: true, hint: "median round-trip in milliseconds" },
{ key: "uptime", label: "Uptime", numeric: true, hint: "30-day availability percentage" },
{ key: "cost", label: "Est. cost", numeric: true, hint: "estimated monthly cost in USD" },
];
const PROVIDER_BADGE: Record<ProviderName, string> = {
AWS: "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-300",
GCP: "border-sky-300 bg-sky-50 text-sky-800 dark:border-sky-400/30 dark:bg-sky-400/10 dark:text-sky-300",
Azure: "border-indigo-300 bg-indigo-50 text-indigo-800 dark:border-indigo-400/30 dark:bg-indigo-400/10 dark:text-indigo-300",
};
function compare(a: Region, b: Region, key: SortKey): number {
const av = a[key];
const bv = b[key];
if (typeof av === "number" && typeof bv === "number") return av - bv;
return String(av).localeCompare(String(bv));
}
function SortGlyph({ state }: { state: SortDir | "none" }) {
return (
<span
aria-hidden="true"
className={
"inline-flex flex-col leading-[0] " +
(state !== "none" ? "tblbd-active" : "")
}
>
<svg
width="9"
height="6"
viewBox="0 0 10 6"
fill="none"
className={
"mb-[1px] transition-opacity " +
(state === "asc"
? "opacity-100 text-indigo-600 dark:text-indigo-400"
: "opacity-30 text-slate-500 dark:text-slate-400")
}
>
<path d="M5 0L10 6H0L5 0Z" fill="currentColor" />
</svg>
<svg
width="9"
height="6"
viewBox="0 0 10 6"
fill="none"
className={
"transition-opacity " +
(state === "desc"
? "opacity-100 text-indigo-600 dark:text-indigo-400"
: "opacity-30 text-slate-500 dark:text-slate-400")
}
>
<path d="M5 6L0 0H10L5 6Z" fill="currentColor" />
</svg>
</span>
);
}
export default function TableBordered() {
const reduce = useReducedMotion();
const uid = useId();
const searchId = `${uid}-search`;
const captionId = `${uid}-caption`;
const [query, setQuery] = useState("");
const [sortKey, setSortKey] = useState<SortKey>("latency");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
const filtered = q
? REGIONS.filter(
(r) =>
r.region.toLowerCase().includes(q) ||
r.city.toLowerCase().includes(q) ||
r.provider.toLowerCase().includes(q),
)
: REGIONS;
const sorted = [...filtered].sort((a, b) => compare(a, b, sortKey));
return sortDir === "asc" ? sorted : sorted.reverse();
}, [query, sortKey, sortDir]);
const totalCost = useMemo(() => rows.reduce((sum, r) => sum + r.cost, 0), [rows]);
function toggleSort(key: SortKey) {
if (key === sortKey) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("asc");
}
}
const containerVar: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.035 } },
};
const rowVar: Variants = {
hidden: { opacity: 0, y: 6 },
show: { opacity: 1, y: 0, transition: { duration: 0.26, ease: [0.16, 1, 0.3, 1] } },
};
const bodyMotion = reduce
? {}
: { variants: containerVar, initial: "hidden" as const, animate: "show" as const };
const rowMotion = reduce ? {} : { variants: rowVar };
const cellBorder = "border border-slate-300 dark:border-slate-700";
return (
<section className="relative w-full bg-white px-4 py-16 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-20">
<style>{`
@keyframes tblbd-nudge {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(1.5px); }
}
.tblbd-active { animation: tblbd-nudge 2.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.tblbd-active { animation: none; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<header className="mb-8 flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Edge network
</p>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
Deployment regions
</h2>
<p className="mt-2 max-w-lg text-sm text-slate-600 dark:text-slate-400">
Live latency and availability across our multi-cloud footprint. Click a column header to sort, or filter by region, city, or provider.
</p>
</div>
<div className="w-full sm:w-64">
<label
htmlFor={searchId}
className="mb-1.5 block text-xs font-medium text-slate-600 dark:text-slate-400"
>
Filter regions
</label>
<div className="relative">
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-slate-400 dark:text-slate-500">
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="9" cy="9" r="6" stroke="currentColor" strokeWidth="1.6" />
<path d="M14 14L18 18" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</span>
<input
id={searchId}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="e.g. london, gcp, us-east"
autoComplete="off"
spellCheck={false}
className="w-full rounded-lg border border-slate-300 bg-white py-2 pl-9 pr-9 text-sm text-slate-900 placeholder:text-slate-400 focus:border-indigo-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
{query.length > 0 && (
<button
type="button"
onClick={() => setQuery("")}
aria-label="Clear filter"
className="absolute inset-y-0 right-2 flex items-center rounded-md px-1 text-slate-400 transition-colors hover:text-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 dark:hover:text-slate-200"
>
<svg width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M5 5L15 15M15 5L5 15" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
</button>
)}
</div>
</div>
</header>
<div className="overflow-x-auto rounded-xl border border-slate-300 dark:border-slate-700">
<table className="w-full border-collapse text-left text-sm">
<caption id={captionId} className="sr-only">
Multi-cloud deployment regions with latency, uptime, and estimated monthly cost. Sortable and filterable.
</caption>
<thead>
<tr className="bg-slate-100 dark:bg-slate-800/60">
{COLUMNS.map((col) => {
const active = col.key === sortKey;
const ariaSort: "ascending" | "descending" | "none" = active
? sortDir === "asc"
? "ascending"
: "descending"
: "none";
return (
<th
key={col.key}
scope="col"
aria-sort={ariaSort}
className={
cellBorder +
" p-0 font-semibold " +
(col.numeric ? "text-right" : "text-left")
}
>
<button
type="button"
onClick={() => toggleSort(col.key)}
aria-label={`Sort by ${col.label} (${col.hint})${
active ? `, currently ${ariaSort}` : ""
}`}
className={
"flex w-full items-center gap-1.5 px-4 py-3 text-xs font-semibold uppercase tracking-wider transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500/70 " +
(col.numeric ? "justify-end" : "justify-start") +
" " +
(active
? "text-slate-900 dark:text-white"
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white")
}
>
<span>{col.label}</span>
<SortGlyph state={active ? sortDir : "none"} />
</button>
</th>
);
})}
</tr>
</thead>
<motion.tbody key={`${sortKey}-${sortDir}`} {...bodyMotion}>
{rows.length === 0 ? (
<tr>
<td
colSpan={COLUMNS.length}
className={cellBorder + " px-4 py-10 text-center text-slate-500 dark:text-slate-400"}
>
No regions match{" "}
<span className="font-medium text-slate-700 dark:text-slate-200">
“{query}”
</span>
.
</td>
</tr>
) : (
rows.map((r) => (
<motion.tr
key={r.id}
{...rowMotion}
className="bg-white transition-colors hover:bg-indigo-50/60 dark:bg-slate-950 dark:hover:bg-indigo-500/10"
>
<th
scope="row"
className={cellBorder + " px-4 py-3 text-left font-normal"}
>
<span className="font-mono text-[13px] font-medium text-slate-900 dark:text-slate-100">
{r.region}
</span>
<span className="block text-xs text-slate-500 dark:text-slate-400">
{r.city}
</span>
</th>
<td className={cellBorder + " px-4 py-3"}>
<span
className={
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium " +
PROVIDER_BADGE[r.provider]
}
>
{r.provider}
</span>
</td>
<td className={cellBorder + " px-4 py-3 text-right font-mono tabular-nums"}>
<span
className={
r.latency <= 30
? "text-emerald-700 dark:text-emerald-400"
: r.latency <= 50
? "text-slate-700 dark:text-slate-300"
: "text-amber-700 dark:text-amber-400"
}
>
{r.latency}
</span>
<span className="text-slate-400 dark:text-slate-500"> ms</span>
</td>
<td className={cellBorder + " px-4 py-3 text-right font-mono tabular-nums text-slate-700 dark:text-slate-300"}>
{r.uptime.toFixed(2)}%
</td>
<td className={cellBorder + " px-4 py-3 text-right font-mono tabular-nums text-slate-900 dark:text-slate-100"}>
${r.cost}
</td>
</motion.tr>
))
)}
</motion.tbody>
{rows.length > 0 && (
<tfoot>
<tr className="bg-slate-50 dark:bg-slate-900/70">
<td
colSpan={4}
className={cellBorder + " px-4 py-3 text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400"}
>
{rows.length} region{rows.length === 1 ? "" : "s"} · combined estimate
</td>
<td className={cellBorder + " px-4 py-3 text-right font-mono font-semibold tabular-nums text-slate-900 dark:text-white"}>
${totalCost.toLocaleString("en-US")}
</td>
</tr>
</tfoot>
)}
</table>
</div>
<p aria-live="polite" className="sr-only">
{rows.length} region{rows.length === 1 ? "" : "s"} shown, sorted by {sortKey} {sortDir === "asc" ? "ascending" : "descending"}.
</p>
<p className="mt-3 text-xs text-slate-500 dark:text-slate-500">
Latency measured from the Frankfurt control plane over the trailing 24 hours. Costs assume the reference workload (4 vCPU, 16 GB, 500 GB egress).
</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 quoteSimilar components
Browse all →
Basic Table
Originalclean basic data table

Striped Table
Originalstriped-row 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

Pagination Table
Originaltable with footer pagination

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

