Striped Table
Original · freestriped-row table
byWeb InnoventixReact + Tailwind
tablestripedtables
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.jsontable-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 quoteSimilar components
Browse all →
Basic Table
Originalclean basic data 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

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

