Status Badges Table
Original · freetable with status badges
byWeb InnoventixReact + Tailwind
tablestatusbadgestables
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-status-badges.jsontable-status-badges.tsx
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
type Status = "live" | "building" | "queued" | "failed" | "rolledback";
type Environment = "Production" | "Staging" | "Preview";
type SortKey = "service" | "environment" | "status" | "duration" | "updated";
type SortDir = "asc" | "desc";
type StatusFilter = Status | "all";
interface Deployment {
id: string;
service: string;
branch: string;
environment: Environment;
status: Status;
durationSec: number;
updatedMinsAgo: number;
author: string;
}
const INITIAL_ROWS: Deployment[] = [
{ id: "dpl_8f21", service: "checkout-api", branch: "main", environment: "Production", status: "live", durationSec: 134, updatedMinsAgo: 4, author: "Priya Nair" },
{ id: "dpl_7a05", service: "web-storefront", branch: "main", environment: "Production", status: "building", durationSec: 92, updatedMinsAgo: 1, author: "Diego Alvarez" },
{ id: "dpl_6c93", service: "auth-gateway", branch: "release/2.4", environment: "Staging", status: "failed", durationSec: 47, updatedMinsAgo: 12, author: "Mara Kessler" },
{ id: "dpl_5b77", service: "search-indexer", branch: "feat/ranking", environment: "Preview", status: "queued", durationSec: 0, updatedMinsAgo: 0, author: "Tomas Fuchs" },
{ id: "dpl_4e12", service: "billing-worker", branch: "main", environment: "Production", status: "live", durationSec: 210, updatedMinsAgo: 22, author: "Aiko Tanaka" },
{ id: "dpl_3d48", service: "notifications-svc", branch: "hotfix/smtp", environment: "Staging", status: "rolledback", durationSec: 168, updatedMinsAgo: 38, author: "Liam O'Brien" },
{ id: "dpl_2a66", service: "media-cdn", branch: "feat/webp", environment: "Preview", status: "live", durationSec: 76, updatedMinsAgo: 9, author: "Sofia Rossi" },
{ id: "dpl_1f39", service: "analytics-pipeline", branch: "main", environment: "Production", status: "building", durationSec: 305, updatedMinsAgo: 2, author: "Noah Weber" },
{ id: "dpl_0c84", service: "graphql-edge", branch: "chore/deps", environment: "Staging", status: "failed", durationSec: 58, updatedMinsAgo: 51, author: "Yara Haddad" },
];
const STATUS_META: Record<Status, { label: string; badge: string; animated?: boolean; rank: number }> = {
failed: {
label: "Failed",
rank: 0,
badge:
"bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/25",
},
building: {
label: "Building",
rank: 1,
animated: true,
badge:
"bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-400/25",
},
queued: {
label: "Queued",
rank: 2,
badge:
"bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/25",
},
rolledback: {
label: "Rolled back",
rank: 3,
badge:
"bg-zinc-100 text-zinc-700 ring-zinc-500/20 dark:bg-zinc-500/10 dark:text-zinc-300 dark:ring-zinc-400/25",
},
live: {
label: "Live",
rank: 4,
badge:
"bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25",
},
};
const ENV_META: Record<Environment, string> = {
Production:
"bg-indigo-50 text-indigo-700 ring-indigo-600/15 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20",
Staging:
"bg-violet-50 text-violet-700 ring-violet-600/15 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-400/20",
Preview:
"bg-slate-100 text-slate-600 ring-slate-500/15 dark:bg-slate-800/60 dark:text-slate-300 dark:ring-slate-500/25",
};
const ENV_RANK: Record<Environment, number> = { Production: 0, Staging: 1, Preview: 2 };
const COLUMNS: { key: SortKey; label: string; align: "left" | "right" }[] = [
{ key: "service", label: "Service", align: "left" },
{ key: "environment", label: "Environment", align: "left" },
{ key: "status", label: "Status", align: "left" },
{ key: "duration", label: "Build time", align: "right" },
{ key: "updated", label: "Last updated", align: "right" },
];
const FILTERS: { key: StatusFilter; label: string }[] = [
{ key: "all", label: "All" },
{ key: "live", label: "Live" },
{ key: "building", label: "Building" },
{ key: "queued", label: "Queued" },
{ key: "failed", label: "Failed" },
{ key: "rolledback", label: "Rolled back" },
];
const 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-950";
function formatDuration(sec: number): string {
if (sec <= 0) return "—";
if (sec < 60) return `${sec}s`;
return `${Math.floor(sec / 60)}m ${sec % 60}s`;
}
function formatUpdated(mins: number): string {
if (mins <= 0) return "just now";
if (mins < 60) return `${mins}m ago`;
const h = Math.floor(mins / 60);
return `${h}h ago`;
}
function StatusIcon({ status }: { status: Status }) {
const common = { width: 13, height: 13, viewBox: "0 0 24 24", fill: "none", "aria-hidden": true } as const;
if (status === "live") {
return (
<svg {...common}>
<path d="M20 6 9 17l-5-5" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
if (status === "building") {
return (
<svg {...common} className="tsbadge-spin">
<path d="M12 3a9 9 0 1 0 9 9" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" />
</svg>
);
}
if (status === "queued") {
return (
<svg {...common}>
<circle cx={12} cy={12} r={9} stroke="currentColor" strokeWidth={2} />
<path d="M12 7.5V12l3 2" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
if (status === "failed") {
return (
<svg {...common}>
<circle cx={12} cy={12} r={9} stroke="currentColor" strokeWidth={2} />
<path d="m15 9-6 6M9 9l6 6" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" />
</svg>
);
}
return (
<svg {...common}>
<path d="M9 14 4 9l5-5" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<path d="M4 9h11a5 5 0 0 1 0 10h-3" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function SortGlyph({ state }: { state: "asc" | "desc" | "none" }) {
return (
<svg width={12} height={12} viewBox="0 0 24 24" fill="none" aria-hidden="true" className="shrink-0">
<path
d="M8 10l4-4 4 4"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={state === "asc" ? "opacity-100" : "opacity-30"}
/>
<path
d="M8 14l4 4 4-4"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={state === "desc" ? "opacity-100" : "opacity-30"}
/>
</svg>
);
}
export default function TableStatusBadges() {
const reduce = useReducedMotion();
const searchId = useId();
const selectAllRef = useRef<HTMLInputElement>(null);
const [rows, setRows] = useState<Deployment[]>(INITIAL_ROWS);
const [query, setQuery] = useState<string>("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [sortKey, setSortKey] = useState<SortKey>("updated");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
const [action, setAction] = useState<string>("");
const searchFiltered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(r) =>
r.service.toLowerCase().includes(q) ||
r.branch.toLowerCase().includes(q) ||
r.author.toLowerCase().includes(q),
);
}, [rows, query]);
const counts = useMemo(() => {
const c: Record<StatusFilter, number> = { all: 0, live: 0, building: 0, queued: 0, failed: 0, rolledback: 0 };
for (const r of searchFiltered) {
c[r.status] += 1;
c.all += 1;
}
return c;
}, [searchFiltered]);
const visible = useMemo(() => {
const base = statusFilter === "all" ? searchFiltered : searchFiltered.filter((r) => r.status === statusFilter);
const dir = sortDir === "asc" ? 1 : -1;
return [...base].sort((a, b) => {
let cmp = 0;
switch (sortKey) {
case "service":
cmp = a.service.localeCompare(b.service);
break;
case "environment":
cmp = ENV_RANK[a.environment] - ENV_RANK[b.environment];
break;
case "status":
cmp = STATUS_META[a.status].rank - STATUS_META[b.status].rank;
break;
case "duration":
cmp = a.durationSec - b.durationSec;
break;
case "updated":
cmp = a.updatedMinsAgo - b.updatedMinsAgo;
break;
}
if (cmp === 0) cmp = a.service.localeCompare(b.service);
return cmp * dir;
});
}, [searchFiltered, statusFilter, sortKey, sortDir]);
const visibleIds = useMemo(() => visible.map((r) => r.id), [visible]);
const selectedVisible = visibleIds.filter((id) => selected.has(id)).length;
const allVisibleSelected = visible.length > 0 && selectedVisible === visible.length;
useEffect(() => {
if (selectAllRef.current) {
selectAllRef.current.indeterminate = selectedVisible > 0 && !allVisibleSelected;
}
}, [selectedVisible, allVisibleSelected]);
function toggleSort(key: SortKey) {
if (key === sortKey) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir(key === "service" || key === "environment" ? "asc" : "asc");
}
}
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 toggleAll() {
setSelected((prev) => {
const next = new Set(prev);
if (allVisibleSelected) {
for (const id of visibleIds) next.delete(id);
} else {
for (const id of visibleIds) next.add(id);
}
return next;
});
}
function redeploySelected() {
const ids = selected;
if (ids.size === 0) return;
const n = ids.size;
setRows((prev) =>
prev.map((r) => (ids.has(r.id) ? { ...r, status: "building", durationSec: 0, updatedMinsAgo: 0 } : r)),
);
setSelected(new Set());
setAction(`${n} deployment${n === 1 ? "" : "s"} queued for redeploy.`);
}
function clearSelection() {
setSelected(new Set());
setAction("Selection cleared.");
}
const summary = `Showing ${visible.length} of ${rows.length} deployments${
statusFilter === "all" ? "" : ` with status ${STATUS_META[statusFilter].label.toLowerCase()}`
}.`;
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:px-6 lg:py-24">
<style>{`
@keyframes tsbadge-spin { to { transform: rotate(360deg); } }
@keyframes tsbadge-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.tsbadge-spin { animation: tsbadge-spin 0.8s linear infinite; transform-origin: center; }
@media (prefers-reduced-motion: reduce) {
.tsbadge-spin { animation: none !important; }
}
`}</style>
<motion.div
initial={reduce ? false : { opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, ease: "easeOut" }}
className="mx-auto w-full max-w-5xl"
>
<header className="mb-6 flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-600 text-white dark:bg-indigo-500">
<svg width={14} height={14} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 7h16M4 12h16M4 17h10" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
</svg>
</span>
<h2 className="text-lg font-semibold tracking-tight sm:text-xl">Deployment activity</h2>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400">
Live pipeline status across every service and environment. Sort, filter, and requeue builds.
</p>
</header>
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900/60">
{/* Toolbar */}
<div className="flex flex-col gap-4 border-b border-slate-200 p-4 dark:border-slate-800 sm:p-5">
<div className="relative">
<label htmlFor={searchId} className="sr-only">
Search deployments by service, branch, or author
</label>
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500">
<svg width={16} height={16} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx={11} cy={11} r={7} stroke="currentColor" strokeWidth={2} />
<path d="m20 20-3.5-3.5" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
</svg>
</span>
<input
id={searchId}
type="text"
inputMode="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search service, branch, or author…"
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 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500 ${RING}`}
/>
{query !== "" && (
<button
type="button"
onClick={() => setQuery("")}
aria-label="Clear search"
className={`absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-300 ${RING}`}
>
<svg width={15} height={15} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
</svg>
</button>
)}
</div>
<div role="group" aria-label="Filter by status" className="flex flex-wrap gap-2">
{FILTERS.map((f) => {
const isActive = statusFilter === f.key;
const count = counts[f.key];
return (
<button
key={f.key}
type="button"
aria-pressed={isActive}
onClick={() => setStatusFilter(f.key)}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${RING} ${
isActive
? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500"
: "border-slate-300 bg-white text-slate-600 hover:border-slate-400 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-slate-100"
}`}
>
{f.label}
<span
className={`inline-flex min-w-[1.25rem] justify-center rounded-full px-1 text-[0.65rem] font-semibold tabular-nums ${
isActive
? "bg-white/25 text-white"
: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400"
}`}
>
{count}
</span>
</button>
);
})}
</div>
</div>
{/* Bulk action bar */}
{selected.size > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-indigo-200 bg-indigo-50 px-4 py-2.5 dark:border-indigo-500/25 dark:bg-indigo-500/10 sm:px-5">
<p className="text-sm font-medium text-indigo-800 dark:text-indigo-200">
<span className="tabular-nums">{selected.size}</span> selected
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={redeploySelected}
className={`inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400 ${RING}`}
>
<svg width={14} height={14} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M20 12a8 8 0 1 1-2.3-5.6M20 4v4h-4"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Redeploy
</button>
<button
type="button"
onClick={clearSelection}
className={`rounded-lg border border-indigo-300 bg-white px-3 py-1.5 text-xs font-semibold text-indigo-700 hover:bg-indigo-100 dark:border-indigo-500/30 dark:bg-transparent dark:text-indigo-200 dark:hover:bg-indigo-500/15 ${RING}`}
>
Clear
</button>
</div>
</div>
)}
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full min-w-[46rem] border-collapse text-left text-sm">
<caption className="sr-only">
Deployments. Use the column header buttons to sort and the checkboxes to select rows.
</caption>
<thead>
<tr className="border-b border-slate-200 dark:border-slate-800">
<th scope="col" className="w-12 px-4 py-3">
<span className="sr-only">Select</span>
<input
ref={selectAllRef}
type="checkbox"
checked={allVisibleSelected}
onChange={toggleAll}
disabled={visible.length === 0}
aria-label="Select all visible deployments"
className={`h-4 w-4 cursor-pointer rounded border-slate-300 accent-indigo-600 dark:border-slate-600 ${RING}`}
/>
</th>
{COLUMNS.map((col) => {
const isActive = sortKey === col.key;
const ariaSort: "ascending" | "descending" | "none" = isActive
? sortDir === "asc"
? "ascending"
: "descending"
: "none";
return (
<th
key={col.key}
scope="col"
aria-sort={ariaSort}
className="px-4 py-3 font-medium text-slate-500 dark:text-slate-400"
>
<button
type="button"
onClick={() => toggleSort(col.key)}
className={`group -mx-1.5 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-semibold uppercase tracking-wide hover:text-slate-900 dark:hover:text-slate-100 ${RING} ${
col.align === "right" ? "flex-row-reverse" : ""
} ${isActive ? "text-slate-900 dark:text-slate-100" : ""}`}
>
<span>{col.label}</span>
<SortGlyph state={isActive ? sortDir : "none"} />
</button>
</th>
);
})}
</tr>
</thead>
<tbody>
{visible.length === 0 ? (
<tr>
<td colSpan={COLUMNS.length + 1} className="px-4 py-16 text-center">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">No deployments match</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Try a different search or clear the status filter.
</p>
</td>
</tr>
) : (
visible.map((r) => {
const isSelected = selected.has(r.id);
const meta = STATUS_META[r.status];
return (
<motion.tr
key={r.id}
layout={reduce ? false : "position"}
transition={{ duration: 0.25, ease: "easeOut" }}
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 align-middle">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleRow(r.id)}
aria-label={`Select ${r.service}`}
className={`h-4 w-4 cursor-pointer rounded border-slate-300 accent-indigo-600 dark:border-slate-600 ${RING}`}
/>
</td>
<td className="px-4 py-3 align-middle">
<div className="font-medium text-slate-900 dark:text-slate-100">{r.service}</div>
<div className="mt-0.5 flex items-center gap-1 font-mono text-xs text-slate-500 dark:text-slate-400">
<svg width={11} height={11} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx={6} cy={6} r={3} stroke="currentColor" strokeWidth={2} />
<circle cx={6} cy={18} r={3} stroke="currentColor" strokeWidth={2} />
<circle cx={18} cy={9} r={3} stroke="currentColor" strokeWidth={2} />
<path d="M18 12v.5a3 3 0 0 1-3 3H9M6 9v6" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
</svg>
{r.branch}
</div>
</td>
<td className="px-4 py-3 align-middle">
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${ENV_META[r.environment]}`}
>
{r.environment}
</span>
</td>
<td className="px-4 py-3 align-middle">
<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}`}
>
<StatusIcon status={r.status} />
{meta.label}
</span>
</td>
<td className="px-4 py-3 text-right align-middle tabular-nums text-slate-600 dark:text-slate-300">
{formatDuration(r.durationSec)}
</td>
<td className="px-4 py-3 text-right align-middle">
<div className="tabular-nums text-slate-600 dark:text-slate-300">
{formatUpdated(r.updatedMinsAgo)}
</div>
<div className="mt-0.5 text-xs text-slate-400 dark:text-slate-500">{r.author}</div>
</td>
</motion.tr>
);
})
)}
</tbody>
</table>
</div>
{/* Footer summary */}
<div className="border-t border-slate-200 px-4 py-3 dark:border-slate-800 sm:px-5">
<p aria-live="polite" className="text-xs text-slate-500 dark:text-slate-400">
{summary}
</p>
</div>
</div>
<p aria-live="assertive" className="sr-only">
{action}
</p>
</motion.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

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

Compact Table
Originaldense compact table

