Expandable Table
Original · freetable with expandable rows
byWeb InnoventixReact + Tailwind
tableexpandabletables
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-expandable.jsontable-expandable.tsx
"use client";
import { Fragment, useCallback, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type DeployStatus = "succeeded" | "failed" | "building" | "queued";
type Environment = "Production" | "Staging" | "Preview";
type CheckState = "pass" | "fail" | "running" | "pending";
type FileKind = "added" | "modified" | "removed";
interface DeployCheck {
label: string;
state: CheckState;
detail: string;
}
interface ChangedFile {
path: string;
kind: FileKind;
}
interface Deployment {
id: string;
service: string;
status: DeployStatus;
environment: Environment;
duration: string;
deployedAt: string;
commit: string;
sha: string;
branch: string;
author: string;
checks: DeployCheck[];
files: ChangedFile[];
}
const deployments: Deployment[] = [
{
id: "checkout-service",
service: "checkout-service",
status: "succeeded",
environment: "Production",
duration: "3m 42s",
deployedAt: "2h ago",
commit: "Fix idempotency key collision on retried charges",
sha: "a1f9c2e",
branch: "main",
author: "Priya Nair",
checks: [
{ label: "Unit tests", state: "pass", detail: "428 passed" },
{ label: "Integration", state: "pass", detail: "61 passed" },
{ label: "Lint & types", state: "pass", detail: "no issues" },
{ label: "Security scan", state: "pass", detail: "0 vulnerabilities" },
],
files: [
{ path: "services/checkout/idempotency.ts", kind: "added" },
{ path: "services/checkout/charge.ts", kind: "modified" },
{ path: "tests/charge.spec.ts", kind: "modified" },
],
},
{
id: "web-storefront",
service: "web-storefront",
status: "building",
environment: "Preview",
duration: "1m 08s",
deployedAt: "just now",
commit: "Migrate hero carousel to server components",
sha: "7d3b410",
branch: "feat/rsc-hero",
author: "Devon Alvarez",
checks: [
{ label: "Unit tests", state: "running", detail: "312 / 540" },
{ label: "Bundle budget", state: "running", detail: "measuring" },
{ label: "Lint & types", state: "pass", detail: "no issues" },
{ label: "Visual diff", state: "pending", detail: "queued" },
],
files: [
{ path: "app/(shop)/hero-carousel.tsx", kind: "modified" },
{ path: "app/(shop)/hero.client.tsx", kind: "removed" },
{ path: "lib/media/loader.ts", kind: "added" },
],
},
{
id: "auth-gateway",
service: "auth-gateway",
status: "failed",
environment: "Production",
duration: "0m 54s",
deployedAt: "18m ago",
commit: "Rotate JWT signing keys to AWS KMS",
sha: "c05e8ab",
branch: "main",
author: "Marcus Bell",
checks: [
{ label: "Unit tests", state: "pass", detail: "203 passed" },
{ label: "Integration", state: "fail", detail: "KMS: permission denied" },
{ label: "Lint & types", state: "pass", detail: "no issues" },
{ label: "Security scan", state: "pass", detail: "0 vulnerabilities" },
],
files: [
{ path: "services/auth/jwt.ts", kind: "modified" },
{ path: "infra/kms-policy.json", kind: "added" },
],
},
{
id: "search-indexer",
service: "search-indexer",
status: "succeeded",
environment: "Staging",
duration: "6m 20s",
deployedAt: "1h ago",
commit: "Add typo-tolerant fuzzy matching to product search",
sha: "4be1d77",
branch: "feat/fuzzy",
author: "Hana Okafor",
checks: [
{ label: "Unit tests", state: "pass", detail: "894 passed" },
{ label: "Integration", state: "pass", detail: "reindex verified" },
{ label: "Lint & types", state: "pass", detail: "no issues" },
],
files: [
{ path: "indexer/tokenizer.ts", kind: "modified" },
{ path: "indexer/fuzzy.ts", kind: "added" },
{ path: "config/ranking.yaml", kind: "modified" },
],
},
{
id: "billing-worker",
service: "billing-worker",
status: "queued",
environment: "Staging",
duration: "—",
deployedAt: "queued",
commit: "Batch dunning emails by billing cycle",
sha: "9ff02c1",
branch: "fix/dunning",
author: "Priya Nair",
checks: [
{ label: "Unit tests", state: "pending", detail: "waiting for runner" },
{ label: "Integration", state: "pending", detail: "waiting for runner" },
],
files: [],
},
{
id: "notifications-api",
service: "notifications-api",
status: "succeeded",
environment: "Production",
duration: "2m 11s",
deployedAt: "4h ago",
commit: "Debounce duplicate push notifications within a 30s window",
sha: "e21770d",
branch: "main",
author: "Sam Whitfield",
checks: [
{ label: "Unit tests", state: "pass", detail: "176 passed" },
{ label: "Integration", state: "pass", detail: "34 passed" },
{ label: "Lint & types", state: "pass", detail: "no issues" },
],
files: [
{ path: "services/push/debounce.ts", kind: "added" },
{ path: "services/push/dispatch.ts", kind: "modified" },
],
},
];
const statusMeta: Record<
DeployStatus,
{ label: string; badge: string; dot: string; pulse: boolean }
> = {
succeeded: {
label: "Succeeded",
badge:
"border-emerald-300/70 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300",
dot: "bg-emerald-500",
pulse: false,
},
failed: {
label: "Failed",
badge:
"border-rose-300/70 bg-rose-50 text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300",
dot: "bg-rose-500",
pulse: false,
},
building: {
label: "Building",
badge:
"border-amber-300/70 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300",
dot: "bg-amber-500",
pulse: true,
},
queued: {
label: "Queued",
badge:
"border-sky-300/70 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-300",
dot: "bg-sky-500",
pulse: true,
},
};
const fileKindMeta: Record<FileKind, { sign: string; className: string; label: string }> = {
added: {
sign: "+",
label: "added",
className: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
},
modified: {
sign: "~",
label: "modified",
className: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
},
removed: {
sign: "−",
label: "removed",
className: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
},
};
const envFilters: { value: "all" | Environment; label: string }[] = [
{ value: "all", label: "All" },
{ value: "Production", label: "Production" },
{ value: "Staging", label: "Staging" },
{ value: "Preview", label: "Preview" },
];
function ChevronIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<path
d="M7.5 5l5 5-5 5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function CheckStateIcon({ state }: { state: CheckState }) {
if (state === "pass") {
return (
<svg viewBox="0 0 20 20" className="h-4 w-4 text-emerald-500" aria-hidden="true">
<circle cx="10" cy="10" r="9" fill="currentColor" opacity="0.15" />
<path
d="M6 10.4l2.6 2.6L14.2 7"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
if (state === "fail") {
return (
<svg viewBox="0 0 20 20" className="h-4 w-4 text-rose-500" aria-hidden="true">
<circle cx="10" cy="10" r="9" fill="currentColor" opacity="0.15" />
<path
d="M7 7l6 6M13 7l-6 6"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
);
}
if (state === "running") {
return (
<span
className="texp-dot-pulse inline-flex h-4 w-4 items-center justify-center"
aria-hidden="true"
>
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
</span>
);
}
return (
<span className="inline-flex h-4 w-4 items-center justify-center" aria-hidden="true">
<span className="h-2.5 w-2.5 rounded-full border-2 border-slate-300 dark:border-slate-600" />
</span>
);
}
const checkStateLabel: Record<CheckState, string> = {
pass: "passed",
fail: "failed",
running: "running",
pending: "pending",
};
export default function TableExpandable() {
const prefersReduced = useReducedMotion();
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(["checkout-service"]));
const [env, setEnv] = useState<"all" | Environment>("all");
const [copiedSha, setCopiedSha] = useState<string | null>(null);
const copyTimer = useRef<number | null>(null);
const filtered = env === "all" ? deployments : deployments.filter((d) => d.environment === env);
const allExpanded = filtered.length > 0 && filtered.every((d) => expanded.has(d.id));
const toggle = useCallback((id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleAll = useCallback(() => {
setExpanded((prev) => {
const everyOpen = filtered.length > 0 && filtered.every((d) => prev.has(d.id));
if (everyOpen) {
const next = new Set(prev);
filtered.forEach((d) => next.delete(d.id));
return next;
}
const next = new Set(prev);
filtered.forEach((d) => next.add(d.id));
return next;
});
}, [filtered]);
const copySha = useCallback((sha: string) => {
if (typeof navigator !== "undefined" && navigator.clipboard) {
navigator.clipboard.writeText(sha).catch(() => undefined);
}
setCopiedSha(sha);
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopiedSha(null), 1600);
}, []);
const panelTransition = prefersReduced
? { duration: 0 }
: { duration: 0.28, ease: "easeOut" as const };
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 sm:py-24">
<style>{`
@keyframes texp-dot-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.6); }
}
.texp-dot-pulse { animation: texp-dot-pulse 1.1s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.texp-dot-pulse { animation: none !important; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<header className="mb-6 flex flex-col gap-1">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Delivery
</p>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
Deployment activity
</h2>
<p className="max-w-2xl text-sm text-slate-600 dark:text-slate-400">
Every push to your services, with build checks and changed files one expand away.
</p>
</header>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
{/* Toolbar */}
<div className="flex flex-col gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<div
className="flex flex-wrap items-center gap-1 rounded-lg bg-slate-100 p-1 dark:bg-slate-800/70"
role="group"
aria-label="Filter deployments by environment"
>
{envFilters.map((f) => {
const active = env === f.value;
return (
<button
key={f.value}
type="button"
aria-pressed={active}
onClick={() => setEnv(f.value)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors 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:focus-visible:ring-offset-slate-800 ${
active
? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
{f.label}
</button>
);
})}
</div>
<div className="flex items-center gap-3">
<span className="text-xs tabular-nums text-slate-500 dark:text-slate-400">
{filtered.length} {filtered.length === 1 ? "deploy" : "deploys"}
</span>
<button
type="button"
onClick={toggleAll}
disabled={filtered.length === 0}
aria-pressed={allExpanded}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
<ChevronIcon
className={`h-3.5 w-3.5 transition-transform duration-200 motion-reduce:transition-none ${
allExpanded ? "rotate-90" : ""
}`}
/>
{allExpanded ? "Collapse all" : "Expand all"}
</button>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] border-collapse text-left">
<caption className="sr-only">
Recent deployments. Activate a row to reveal commit, checks, and changed files.
</caption>
<thead>
<tr className="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">
<th scope="col" className="px-4 py-3 font-medium sm:px-6">
Deployment
</th>
<th scope="col" className="px-4 py-3 font-medium">
Status
</th>
<th scope="col" className="hidden px-4 py-3 font-medium md:table-cell">
Environment
</th>
<th scope="col" className="hidden px-4 py-3 font-medium lg:table-cell">
Duration
</th>
<th scope="col" className="hidden px-4 py-3 font-medium sm:table-cell">
Deployed
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-800">
{filtered.map((d) => {
const open = expanded.has(d.id);
const meta = statusMeta[d.status];
const detailId = `texp-detail-${d.id}`;
return (
<Fragment key={d.id}>
<tr
className={`transition-colors ${
open
? "bg-slate-50/80 dark:bg-slate-800/40"
: "hover:bg-slate-50 dark:hover:bg-slate-800/30"
}`}
>
<td className="px-2 py-1 sm:px-4">
<button
type="button"
onClick={() => toggle(d.id)}
aria-expanded={open}
aria-controls={detailId}
className="flex w-full items-center gap-3 rounded-lg px-2 py-3 text-left transition-colors 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-900"
>
<span className="flex h-6 w-6 flex-none items-center justify-center rounded-md border border-slate-200 text-slate-500 dark:border-slate-700 dark:text-slate-400">
<ChevronIcon
className={`h-4 w-4 transition-transform duration-200 motion-reduce:transition-none ${
open ? "rotate-90" : ""
}`}
/>
</span>
<span className="min-w-0">
<span className="block truncate font-mono text-sm font-medium text-slate-900 dark:text-white">
{d.service}
</span>
<span className="block truncate text-xs text-slate-500 dark:text-slate-400">
{d.commit}
</span>
</span>
</button>
</td>
<td className="px-4 py-3 align-middle">
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium ${meta.badge}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${meta.dot} ${
meta.pulse ? "texp-dot-pulse" : ""
}`}
/>
{meta.label}
</span>
</td>
<td className="hidden px-4 py-3 align-middle text-sm text-slate-600 dark:text-slate-300 md:table-cell">
{d.environment}
</td>
<td className="hidden px-4 py-3 align-middle text-sm tabular-nums text-slate-600 dark:text-slate-300 lg:table-cell">
{d.duration}
</td>
<td className="hidden px-4 py-3 align-middle text-sm text-slate-500 dark:text-slate-400 sm:table-cell">
{d.deployedAt}
</td>
</tr>
<tr>
<td colSpan={5} className="p-0">
<div id={detailId}>
<AnimatePresence initial={false}>
{open && (
<motion.div
key="panel"
role="region"
aria-label={`Details for ${d.service} deployment ${d.sha}`}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={panelTransition}
className="overflow-hidden bg-slate-50/60 dark:bg-slate-800/25"
>
<div className="grid gap-x-8 gap-y-6 border-t border-slate-200 px-4 py-5 dark:border-slate-800 sm:px-6 lg:grid-cols-12">
{/* Commit */}
<div className="lg:col-span-5">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Commit
</h3>
<p className="mt-2 text-sm text-slate-800 dark:text-slate-200">
{d.commit}
</p>
<dl className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
<div className="flex items-center gap-1">
<dt className="sr-only">Author</dt>
<dd>{d.author}</dd>
</div>
<div className="flex items-center gap-1">
<dt className="sr-only">Branch</dt>
<dd className="font-mono text-slate-600 dark:text-slate-300">
{d.branch}
</dd>
</div>
</dl>
<div className="mt-3 flex items-center gap-2">
<code className="rounded-md bg-slate-200/70 px-2 py-1 font-mono text-xs text-slate-700 dark:bg-slate-700/60 dark:text-slate-200">
{d.sha}
</code>
<button
type="button"
onClick={() => copySha(d.sha)}
className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-50 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-800"
>
{copiedSha === d.sha ? (
<>
<svg
viewBox="0 0 20 20"
className="h-3.5 w-3.5 text-emerald-500"
aria-hidden="true"
>
<path
d="M5 10.5l3.2 3.2L15 7"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Copied
</>
) : (
<>
<svg
viewBox="0 0 20 20"
className="h-3.5 w-3.5"
fill="none"
aria-hidden="true"
>
<rect
x="7"
y="7"
width="9"
height="9"
rx="2"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M13 4H6a2 2 0 00-2 2v7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
Copy SHA
</>
)}
</button>
<span aria-live="polite" className="sr-only">
{copiedSha === d.sha ? `Copied ${d.sha}` : ""}
</span>
</div>
</div>
{/* Checks */}
<div className="lg:col-span-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Checks
</h3>
<ul className="mt-2 space-y-2">
{d.checks.map((c) => (
<li key={c.label} className="flex items-center gap-2.5">
<CheckStateIcon state={c.state} />
<span className="text-sm text-slate-700 dark:text-slate-200">
{c.label}
</span>
<span className="ml-auto text-xs tabular-nums text-slate-500 dark:text-slate-400">
{c.detail}
</span>
<span className="sr-only">
{checkStateLabel[c.state]}
</span>
</li>
))}
</ul>
</div>
{/* Files */}
<div className="lg:col-span-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Changed files
</h3>
{d.files.length === 0 ? (
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
No files yet — deploy has not started.
</p>
) : (
<ul className="mt-2 space-y-1.5">
{d.files.map((f) => {
const fk = fileKindMeta[f.kind];
return (
<li key={f.path} className="flex items-center gap-2">
<span
className={`flex h-5 w-5 flex-none items-center justify-center rounded font-mono text-xs font-bold ${fk.className}`}
aria-hidden="true"
>
{fk.sign}
</span>
<span className="truncate font-mono text-xs text-slate-600 dark:text-slate-300">
{f.path}
</span>
<span className="sr-only">{fk.label}</span>
</li>
);
})}
</ul>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</td>
</tr>
</Fragment>
);
})}
</tbody>
</table>
</div>
{filtered.length === 0 && (
<p className="px-6 py-10 text-center text-sm text-slate-500 dark:text-slate-400">
No deployments in this environment yet.
</p>
)}
</div>
<p className="mt-3 px-1 text-xs text-slate-500 dark:text-slate-400">
Tip: use Tab to move between rows and Space or Enter to expand.
</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

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

Actions Table
Originaltable with row action menus

Status Badges Table
Originaltable with status badges

Compact Table
Originaldense compact table

