Web InnoventixFreeCode

Striped Table

Original · free

striped-row table

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

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

type Status = "healthy" | "degraded" | "down";
type SortKey = "service" | "team" | "rpm" | "p95" | "errorRate" | "status";
type SortDir = "asc" | "desc";

interface ServiceRow {
  id: string;
  service: string;
  team: string;
  rpm: number;
  p95: number;
  errorRate: number;
  status: Status;
}

interface Column {
  key: SortKey;
  label: string;
  numeric: boolean;
}

const ROWS: ServiceRow[] = [
  { id: "api-gateway", service: "api-gateway", team: "Platform", rpm: 184203, p95: 118, errorRate: 0.04, status: "healthy" },
  { id: "checkout-svc", service: "checkout-svc", team: "Payments", rpm: 42870, p95: 342, errorRate: 1.87, status: "down" },
  { id: "search-index", service: "search-index", team: "Discovery", rpm: 96541, p95: 209, errorRate: 0.62, status: "degraded" },
  { id: "auth-service", service: "auth-service", team: "Identity", rpm: 133090, p95: 74, errorRate: 0.02, status: "healthy" },
  { id: "media-encoder", service: "media-encoder", team: "Media", rpm: 8120, p95: 1284, errorRate: 0.31, status: "degraded" },
  { id: "notify-worker", service: "notify-worker", team: "Messaging", rpm: 51377, p95: 156, errorRate: 0.09, status: "healthy" },
  { id: "recommendation", service: "recommendation", team: "ML Platform", rpm: 27644, p95: 488, errorRate: 0.74, status: "degraded" },
  { id: "billing-cron", service: "billing-cron", team: "Payments", rpm: 942, p95: 61, errorRate: 0.0, status: "healthy" },
  { id: "image-cdn", service: "image-cdn", team: "Edge", rpm: 301885, p95: 39, errorRate: 0.01, status: "healthy" },
  { id: "session-store", service: "session-store", team: "Platform", rpm: 210448, p95: 22, errorRate: 2.41, status: "down" },
  { id: "webhook-relay", service: "webhook-relay", team: "Integrations", rpm: 18902, p95: 233, errorRate: 0.18, status: "healthy" },
  { id: "fraud-scorer", service: "fraud-scorer", team: "Risk", rpm: 44210, p95: 611, errorRate: 0.53, status: "degraded" },
];

const COLUMNS: Column[] = [
  { key: "service", label: "Service", numeric: false },
  { key: "team", label: "Team", numeric: false },
  { key: "rpm", label: "Req / min", numeric: true },
  { key: "p95", label: "p95 (ms)", numeric: true },
  { key: "errorRate", label: "Error rate", numeric: true },
  { key: "status", label: "Status", numeric: false },
];

const STATUS_RANK: Record<Status, number> = { healthy: 0, degraded: 1, down: 2 };

const STATUS_META: Record<
  Status,
  { label: string; dot: string; badge: string; pulse: boolean }
> = {
  healthy: {
    label: "Healthy",
    dot: "bg-emerald-500",
    badge:
      "text-emerald-700 bg-emerald-50 ring-emerald-600/20 dark:text-emerald-300 dark:bg-emerald-500/10 dark:ring-emerald-400/20",
    pulse: false,
  },
  degraded: {
    label: "Degraded",
    dot: "bg-amber-500",
    badge:
      "text-amber-700 bg-amber-50 ring-amber-600/20 dark:text-amber-300 dark:bg-amber-500/10 dark:ring-amber-400/20",
    pulse: true,
  },
  down: {
    label: "Down",
    dot: "bg-rose-500",
    badge:
      "text-rose-700 bg-rose-50 ring-rose-600/20 dark:text-rose-300 dark:bg-rose-500/10 dark:ring-rose-400/20",
    pulse: true,
  },
};

const nf = new Intl.NumberFormat("en-US");

function sortValue(row: ServiceRow, key: SortKey): number | string {
  if (key === "status") return STATUS_RANK[row.status];
  return row[key];
}

function errorClass(rate: number): string {
  if (rate >= 1) return "text-rose-600 dark:text-rose-400 font-semibold";
  if (rate >= 0.3) return "text-amber-600 dark:text-amber-400 font-medium";
  return "text-slate-500 dark:text-slate-400";
}

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

  const [query, setQuery] = useState<string>("");
  const [statusFilter, setStatusFilter] = useState<Status | "all">("all");
  const [sortKey, setSortKey] = useState<SortKey>("rpm");
  const [sortDir, setSortDir] = useState<SortDir>("desc");
  const [selected, setSelected] = useState<Set<string>>(() => new Set<string>());

  const selectAllRef = useRef<HTMLInputElement>(null);

  const visible = useMemo<ServiceRow[]>(() => {
    const q = query.trim().toLowerCase();
    const filtered = ROWS.filter((r) => {
      const matchesQuery =
        q === "" ||
        r.service.toLowerCase().includes(q) ||
        r.team.toLowerCase().includes(q);
      const matchesStatus = statusFilter === "all" || r.status === statusFilter;
      return matchesQuery && matchesStatus;
    });

    const sorted = filtered.slice().sort((a, b) => {
      const av = sortValue(a, sortKey);
      const bv = sortValue(b, sortKey);
      let res: number;
      if (typeof av === "number" && typeof bv === "number") {
        res = av - bv;
      } else {
        res = String(av).localeCompare(String(bv));
      }
      return sortDir === "asc" ? res : -res;
    });

    return sorted;
  }, [query, statusFilter, sortKey, sortDir]);

  const visibleIds = useMemo(() => visible.map((r) => r.id), [visible]);
  const selectedVisible = visibleIds.filter((id) => selected.has(id)).length;
  const allSelected = visibleIds.length > 0 && selectedVisible === visibleIds.length;
  const someSelected = selectedVisible > 0 && !allSelected;

  useEffect(() => {
    if (selectAllRef.current) selectAllRef.current.indeterminate = someSelected;
  }, [someSelected]);

  const totalReq = visible.reduce((sum, r) => sum + r.rpm, 0);

  function toggleSort(key: SortKey) {
    if (sortKey === key) {
      setSortDir((d) => (d === "asc" ? "desc" : "asc"));
    } else {
      const col = COLUMNS.find((c) => c.key === key);
      setSortKey(key);
      setSortDir(col && col.numeric ? "desc" : "asc");
    }
  }

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

  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 resetFilters() {
    setQuery("");
    setStatusFilter("all");
  }

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

  const checkboxCls =
    "h-4 w-4 shrink-0 cursor-pointer rounded border-slate-300 text-indigo-600 accent-indigo-600 " +
    "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-600 " +
    "dark:focus-visible:ring-offset-slate-900";

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-20 lg:px-8 dark:bg-slate-950">
      <style>{`
        @keyframes tstr-dot-pulse {
          0%, 100% { transform: scale(1); opacity: 0.55; }
          50% { transform: scale(2.1); opacity: 0; }
        }
        .tstr-pulse { animation: tstr-dot-pulse 1.9s cubic-bezier(0.22,1,0.36,1) infinite; }
        @media (prefers-reduced-motion: reduce) {
          .tstr-pulse { animation: none !important; }
        }
      `}</style>

      <motion.div
        initial={reduce ? false : { opacity: 0, y: 14 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
        className="mx-auto w-full max-w-5xl"
      >
        {/* Heading */}
        <div className="mb-6 flex flex-col gap-1">
          <span className="text-xs font-semibold uppercase tracking-widest text-indigo-600 dark:text-indigo-400">
            Live reliability
          </span>
          <h2 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
            Service health board
          </h2>
          <p className="max-w-2xl text-sm text-slate-600 dark:text-slate-400">
            Sort by any column, filter by state, and select the services you want to page an
            owner about. Numbers reflect the trailing five-minute window.
          </p>
        </div>

        {/* Controls */}
        <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
          <div className="relative w-full sm:max-w-xs">
            <label htmlFor="tstr-search" className="sr-only">
              Search services or teams
            </label>
            <span
              aria-hidden="true"
              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 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <circle cx="11" cy="11" r="7" />
                <path d="m20 20-3.5-3.5" />
              </svg>
            </span>
            <input
              id="tstr-search"
              type="search"
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              placeholder="Search service or team…"
              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 focus-visible:border-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:placeholder:text-slate-500"
            />
          </div>

          <div className="flex items-center gap-2">
            <label htmlFor="tstr-status" className="sr-only">
              Filter by status
            </label>
            <select
              id="tstr-status"
              value={statusFilter}
              onChange={(e) => setStatusFilter(e.target.value as Status | "all")}
              className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 focus-visible:border-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200"
            >
              <option value="all">All statuses</option>
              <option value="healthy">Healthy</option>
              <option value="degraded">Degraded</option>
              <option value="down">Down</option>
            </select>
            <button
              type="button"
              onClick={resetFilters}
              className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-950"
            >
              Reset
            </button>
          </div>
        </div>

        {/* Table card */}
        <div className="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-slate-800">
          <div className="max-h-[30rem] overflow-auto">
            <table className="w-full border-collapse text-left text-sm">
              <caption className="sr-only">
                Service reliability metrics, sortable and selectable.
              </caption>
              <thead>
                <tr className="sticky top-0 z-10 bg-slate-100/95 backdrop-blur dark:bg-slate-800/95">
                  <th
                    scope="col"
                    className="w-12 px-4 py-3 text-slate-500 dark:text-slate-400"
                  >
                    <input
                      ref={selectAllRef}
                      type="checkbox"
                      className={checkboxCls}
                      checked={allSelected}
                      onChange={toggleAll}
                      aria-label={allSelected ? "Deselect all visible services" : "Select all visible services"}
                    />
                  </th>
                  {COLUMNS.map((col) => (
                    <th
                      key={col.key}
                      scope="col"
                      aria-sort={ariaSortFor(col.key)}
                      className={
                        "px-4 py-3 font-semibold text-slate-600 dark:text-slate-300 " +
                        (col.numeric ? "text-right" : "text-left")
                      }
                    >
                      <button
                        type="button"
                        onClick={() => toggleSort(col.key)}
                        className={
                          "group inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs font-semibold uppercase tracking-wide transition-colors hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:hover:text-indigo-400 dark:focus-visible:ring-offset-slate-800 " +
                          (col.numeric ? "flex-row-reverse" : "")
                        }
                      >
                        <span>{col.label}</span>
                        <SortCaret active={sortKey === col.key} dir={sortDir} />
                      </button>
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {visible.length === 0 ? (
                  <tr>
                    <td
                      colSpan={COLUMNS.length + 1}
                      className="px-4 py-12 text-center text-sm text-slate-500 dark:text-slate-400"
                    >
                      No services match “{query}”. Try clearing the filters.
                    </td>
                  </tr>
                ) : (
                  visible.map((row, index) => {
                    const meta = STATUS_META[row.status];
                    const isSelected = selected.has(row.id);
                    const striped = index % 2 === 1;
                    const rowBg = isSelected
                      ? "bg-indigo-50 dark:bg-indigo-500/10"
                      : striped
                        ? "bg-slate-50/70 dark:bg-slate-800/25"
                        : "bg-transparent";
                    return (
                      <tr
                        key={row.id}
                        className={
                          "border-t border-slate-100 transition-colors hover:bg-indigo-50/70 dark:border-slate-800/70 dark:hover:bg-indigo-500/10 " +
                          rowBg
                        }
                      >
                        <td className="px-4 py-3">
                          <input
                            type="checkbox"
                            className={checkboxCls}
                            checked={isSelected}
                            onChange={() => toggleRow(row.id)}
                            aria-label={`Select ${row.service}`}
                          />
                        </td>
                        <td className="px-4 py-3">
                          <span className="font-mono text-[13px] font-medium text-slate-900 dark:text-white">
                            {row.service}
                          </span>
                        </td>
                        <td className="px-4 py-3 text-slate-600 dark:text-slate-300">
                          {row.team}
                        </td>
                        <td className="px-4 py-3 text-right font-medium text-slate-700 tabular-nums dark:text-slate-200">
                          {nf.format(row.rpm)}
                        </td>
                        <td className="px-4 py-3 text-right text-slate-700 tabular-nums dark:text-slate-200">
                          {nf.format(row.p95)}
                        </td>
                        <td className={"px-4 py-3 text-right tabular-nums " + errorClass(row.errorRate)}>
                          {row.errorRate.toFixed(2)}%
                        </td>
                        <td className="px-4 py-3">
                          <span
                            className={
                              "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset " +
                              meta.badge
                            }
                          >
                            <span className="relative flex h-2 w-2 items-center justify-center">
                              {meta.pulse && (
                                <span
                                  className={"tstr-pulse absolute inline-flex h-2 w-2 rounded-full " + meta.dot}
                                />
                              )}
                              <span className={"relative inline-flex h-2 w-2 rounded-full " + meta.dot} />
                            </span>
                            {meta.label}
                          </span>
                        </td>
                      </tr>
                    );
                  })
                )}
              </tbody>
              <tfoot>
                <tr className="border-t border-slate-200 bg-slate-50/80 dark:border-slate-800 dark:bg-slate-800/40">
                  <td colSpan={3} className="px-4 py-3 text-xs font-medium text-slate-500 dark:text-slate-400">
                    {selected.size > 0
                      ? `${selected.size} selected`
                      : `${visible.length} of ${ROWS.length} services`}
                  </td>
                  <td className="px-4 py-3 text-right text-xs font-semibold text-slate-600 tabular-nums dark:text-slate-300">
                    {nf.format(totalReq)}
                  </td>
                  <td colSpan={3} className="px-4 py-3 text-right text-xs text-slate-400 dark:text-slate-500">
                    total req / min
                  </td>
                </tr>
              </tfoot>
            </table>
          </div>
        </div>

        <p role="status" aria-live="polite" className="sr-only">
          {visible.length} services shown, sorted by {sortKey} {sortDir === "asc" ? "ascending" : "descending"}.
        </p>
      </motion.div>
    </section>
  );
}

function SortCaret({ active, dir }: { active: boolean; dir: SortDir }) {
  return (
    <span
      aria-hidden="true"
      className={
        "inline-flex transition-all duration-200 " +
        (active
          ? "text-indigo-600 dark:text-indigo-400 " + (dir === "asc" ? "rotate-180" : "rotate-0")
          : "text-slate-300 opacity-0 group-hover:opacity-100 dark:text-slate-600")
      }
    >
      <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
        <path d="m6 9 6 6 6-6" />
      </svg>
    </span>
  );
}

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 →