Web InnoventixFreeCode

Bordered Table

Original · free

bordered table

byWeb InnoventixReact + Tailwind
tableborderedtables
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-bordered.json
table-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">
                      &ldquo;{query}&rdquo;
                    </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 quote

Similar components

Browse all →