Sticky Header Table
Original · freesticky header scroll table
byWeb InnoventixReact + Tailwind
tablestickyheadertables
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-sticky-header.jsontable-sticky-header.tsx
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
type Status = "active" | "trial" | "churning";
type SortKey = "name" | "category" | "owner" | "seats" | "monthly" | "renewal" | "status";
type SortDir = "asc" | "desc";
type StatusFilter = Status | "all";
interface Vendor {
id: string;
name: string;
category: string;
owner: string;
seats: number;
monthly: number;
renewal: string;
status: Status;
}
interface Column {
key: SortKey;
label: string;
numeric?: boolean;
}
const VENDORS: Vendor[] = [
{ id: "vnd-figma", name: "Figma", category: "Design", owner: "Priya Nair", seats: 42, monthly: 720, renewal: "2026-09-14", status: "active" },
{ id: "vnd-linear", name: "Linear", category: "Engineering", owner: "Marcus Feld", seats: 30, monthly: 240, renewal: "2026-08-02", status: "active" },
{ id: "vnd-notion", name: "Notion", category: "Operations", owner: "Dana Whitlock", seats: 88, monthly: 704, renewal: "2026-11-20", status: "active" },
{ id: "vnd-slack", name: "Slack", category: "Communication", owner: "Dana Whitlock", seats: 120, monthly: 1080, renewal: "2026-10-05", status: "active" },
{ id: "vnd-datadog", name: "Datadog", category: "Observability", owner: "Sam Okafor", seats: 15, monthly: 1650, renewal: "2026-07-29", status: "churning" },
{ id: "vnd-vercel", name: "Vercel", category: "Infrastructure", owner: "Marcus Feld", seats: 12, monthly: 480, renewal: "2026-12-11", status: "active" },
{ id: "vnd-github", name: "GitHub", category: "Engineering", owner: "Marcus Feld", seats: 64, monthly: 1344, renewal: "2027-01-18", status: "active" },
{ id: "vnd-amplitude", name: "Amplitude", category: "Analytics", owner: "Lena Cho", seats: 8, monthly: 990, renewal: "2026-08-22", status: "trial" },
{ id: "vnd-hubspot", name: "HubSpot", category: "Marketing", owner: "Owen Reyes", seats: 20, monthly: 1600, renewal: "2026-09-30", status: "active" },
{ id: "vnd-zoom", name: "Zoom", category: "Communication", owner: "Dana Whitlock", seats: 150, monthly: 2250, renewal: "2026-10-14", status: "active" },
{ id: "vnd-sentry", name: "Sentry", category: "Observability", owner: "Sam Okafor", seats: 18, monthly: 468, renewal: "2026-07-21", status: "active" },
{ id: "vnd-airtable", name: "Airtable", category: "Operations", owner: "Priya Nair", seats: 35, monthly: 700, renewal: "2026-11-02", status: "trial" },
{ id: "vnd-intercom", name: "Intercom", category: "Support", owner: "Owen Reyes", seats: 22, monthly: 1518, renewal: "2026-12-03", status: "churning" },
{ id: "vnd-retool", name: "Retool", category: "Engineering", owner: "Marcus Feld", seats: 10, monthly: 500, renewal: "2026-08-16", status: "active" },
{ id: "vnd-miro", name: "Miro", category: "Design", owner: "Priya Nair", seats: 40, monthly: 640, renewal: "2026-09-08", status: "active" },
{ id: "vnd-snowflake", name: "Snowflake", category: "Data", owner: "Lena Cho", seats: 6, monthly: 2400, renewal: "2027-02-01", status: "active" },
{ id: "vnd-pagerduty", name: "PagerDuty", category: "Observability", owner: "Sam Okafor", seats: 14, monthly: 574, renewal: "2026-07-19", status: "churning" },
{ id: "vnd-loom", name: "Loom", category: "Communication", owner: "Dana Whitlock", seats: 55, monthly: 440, renewal: "2026-10-27", status: "active" },
{ id: "vnd-segment", name: "Segment", category: "Data", owner: "Lena Cho", seats: 9, monthly: 1170, renewal: "2026-11-15", status: "trial" },
{ id: "vnd-okta", name: "Okta", category: "Security", owner: "Nadia Brooks", seats: 200, monthly: 1600, renewal: "2027-01-06", status: "active" },
{ id: "vnd-1password", name: "1Password", category: "Security", owner: "Nadia Brooks", seats: 210, monthly: 840, renewal: "2026-12-20", status: "active" },
{ id: "vnd-asana", name: "Asana", category: "Operations", owner: "Owen Reyes", seats: 75, monthly: 825, renewal: "2026-09-22", status: "active" },
];
const COLUMNS: Column[] = [
{ key: "name", label: "Vendor" },
{ key: "category", label: "Category" },
{ key: "owner", label: "Owner" },
{ key: "seats", label: "Seats", numeric: true },
{ key: "monthly", label: "Monthly", numeric: true },
{ key: "renewal", label: "Renewal" },
{ key: "status", label: "Status" },
];
const STATUS_TABS: { key: StatusFilter; label: string }[] = [
{ key: "all", label: "All" },
{ key: "active", label: "Active" },
{ key: "trial", label: "Trial" },
{ key: "churning", label: "Churning" },
];
const STATUS_META: Record<Status, { label: string; badge: string; dot: string }> = {
active: {
label: "Active",
badge:
"bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20",
dot: "bg-emerald-500",
},
trial: {
label: "Trial",
badge:
"bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/20",
dot: "bg-amber-500",
},
churning: {
label: "Churning",
badge:
"bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/20",
dot: "bg-rose-500",
},
};
const usd = (n: number): string =>
n.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
const fmtDate = (iso: string): string =>
new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
function compareVendors(a: Vendor, b: Vendor, key: SortKey): number {
if (key === "seats" || key === "monthly") return a[key] - b[key];
if (key === "renewal") return a.renewal.localeCompare(b.renewal);
return String(a[key]).localeCompare(String(b[key]));
}
export default function TableStickyHeader() {
const prefersReduced = useReducedMotion();
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [sortKey, setSortKey] = useState<SortKey>("monthly");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [selected, setSelected] = useState<Set<string>>(new Set());
const selectAllRef = useRef<HTMLInputElement>(null);
const visibleRows = useMemo(() => {
const q = query.trim().toLowerCase();
const filtered = VENDORS.filter((v) => {
const matchesStatus = statusFilter === "all" || v.status === statusFilter;
const matchesQuery =
q === "" ||
v.name.toLowerCase().includes(q) ||
v.category.toLowerCase().includes(q) ||
v.owner.toLowerCase().includes(q);
return matchesStatus && matchesQuery;
});
const dir = sortDir === "asc" ? 1 : -1;
return [...filtered].sort((a, b) => compareVendors(a, b, sortKey) * dir);
}, [query, statusFilter, sortKey, sortDir]);
const visibleIds = useMemo(() => visibleRows.map((v) => v.id), [visibleRows]);
const selectedVisibleCount = useMemo(
() => visibleIds.filter((id) => selected.has(id)).length,
[visibleIds, selected]
);
const allVisibleSelected = visibleIds.length > 0 && selectedVisibleCount === visibleIds.length;
const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected;
useEffect(() => {
if (selectAllRef.current) selectAllRef.current.indeterminate = someVisibleSelected;
}, [someVisibleSelected]);
const totalMonthly = useMemo(
() => visibleRows.reduce((sum, v) => sum + v.monthly, 0),
[visibleRows]
);
const selectedMonthly = useMemo(
() => visibleRows.reduce((sum, v) => (selected.has(v.id) ? sum + v.monthly : sum), 0),
[visibleRows, selected]
);
const meterPct = totalMonthly === 0 ? 0 : Math.round((selectedMonthly / totalMonthly) * 100);
const handleSort = (key: SortKey): void => {
if (key === sortKey) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir(key === "seats" || key === "monthly" ? "desc" : "asc");
}
};
const toggleRow = (id: string): void => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAllVisible = (): void => {
setSelected((prev) => {
const next = new Set(prev);
if (allVisibleSelected) visibleIds.forEach((id) => next.delete(id));
else visibleIds.forEach((id) => next.add(id));
return next;
});
};
const focusRing =
"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";
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:px-8">
<style>{`
@keyframes stickytbl-fade {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
@keyframes stickytbl-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.stickytbl-row { animation: stickytbl-fade 0.35s ease both; }
.stickytbl-dot { animation: stickytbl-pulse 2.2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.stickytbl-row, .stickytbl-dot { animation: none !important; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<header className="mb-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Finance Ops
</p>
<h2 className="mt-2 text-2xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
SaaS subscription ledger
</h2>
<p className="mt-2 max-w-2xl text-sm text-slate-600 dark:text-slate-400">
Every recurring tool, its owner, and its renewal date. Sort any column, filter by status,
and select rows to preview committed spend. The header stays pinned while you scroll.
</p>
</header>
{/* Toolbar */}
<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="stickytbl-search" className="sr-only">
Search vendors, categories, or owners
</label>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-slate-400 dark:text-slate-500"
>
<svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" stroke="currentColor" strokeWidth="1.8">
<circle cx="9" cy="9" r="6" />
<path d="m14 14 3.5 3.5" strokeLinecap="round" />
</svg>
</span>
<input
id="stickytbl-search"
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search vendor, owner, category…"
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-900 dark:text-slate-100 dark:placeholder:text-slate-500 ${focusRing}`}
/>
{query !== "" && (
<button
type="button"
onClick={() => setQuery("")}
aria-label="Clear search"
className={`absolute inset-y-0 right-0 flex items-center rounded-r-lg pr-3 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 ${focusRing}`}
>
<svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" stroke="currentColor" strokeWidth="1.8">
<path d="M6 6l8 8M14 6l-8 8" strokeLinecap="round" />
</svg>
</button>
)}
</div>
<div
role="group"
aria-label="Filter by status"
className="inline-flex rounded-lg border border-slate-300 bg-white p-0.5 dark:border-slate-700 dark:bg-slate-900"
>
{STATUS_TABS.map((tab) => {
const active = statusFilter === tab.key;
return (
<button
key={tab.key}
type="button"
aria-pressed={active}
onClick={() => setStatusFilter(tab.key)}
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${focusRing} ${
active
? "bg-indigo-600 text-white dark:bg-indigo-500"
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
}`}
>
{tab.label}
</button>
);
})}
</div>
</div>
{/* Scroll region with sticky header */}
<div
role="region"
aria-label="Vendor spend table, scrollable"
tabIndex={0}
className={`max-h-[30rem] overflow-auto rounded-xl bg-white shadow-sm ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-slate-800 ${focusRing}`}
>
<table className="w-full min-w-[860px] border-separate border-spacing-0 text-left text-sm">
<caption className="sr-only">
SaaS subscriptions with owner, seats, monthly cost, renewal date, and status. Sortable
columns and selectable rows.
</caption>
<thead>
<tr>
<th
scope="col"
className="sticky top-0 z-20 w-12 border-b border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-700 dark:bg-slate-800"
>
<input
ref={selectAllRef}
type="checkbox"
checked={allVisibleSelected}
onChange={toggleAllVisible}
aria-label="Select all visible vendors"
className={`h-4 w-4 cursor-pointer rounded border-slate-300 text-indigo-600 accent-indigo-600 dark:border-slate-600 ${focusRing}`}
/>
</th>
{COLUMNS.map((col) => {
const active = sortKey === col.key;
const ariaSort: "ascending" | "descending" | "none" = active
? sortDir === "asc"
? "ascending"
: "descending"
: "none";
return (
<th
key={col.key}
scope="col"
aria-sort={ariaSort}
className={`sticky top-0 z-20 whitespace-nowrap border-b border-slate-200 bg-slate-50 px-4 py-3 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 ${
col.numeric ? "text-right" : "text-left"
}`}
>
<button
type="button"
onClick={() => handleSort(col.key)}
className={`group inline-flex items-center gap-1.5 rounded font-semibold uppercase tracking-wide ${
col.numeric ? "flex-row-reverse" : ""
} ${active ? "text-indigo-600 dark:text-indigo-400" : "hover:text-slate-800 dark:hover:text-slate-200"} ${focusRing}`}
>
<span>{col.label}</span>
<motion.span
aria-hidden="true"
className="inline-flex"
animate={{
rotate: active && sortDir === "desc" ? 180 : 0,
opacity: active ? 1 : 0.4,
}}
transition={prefersReduced ? { duration: 0 } : { duration: 0.2 }}
>
<svg viewBox="0 0 16 16" fill="none" className="h-3.5 w-3.5" stroke="currentColor" strokeWidth="2">
<path d="M8 3v10M4 9l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</motion.span>
</button>
</th>
);
})}
</tr>
</thead>
<tbody>
{visibleRows.map((v) => {
const isSelected = selected.has(v.id);
const meta = STATUS_META[v.status];
return (
<tr
key={v.id}
className={`stickytbl-row transition-colors ${
isSelected
? "bg-indigo-50/70 dark:bg-indigo-500/10"
: "hover:bg-slate-50 dark:hover:bg-slate-800/50"
}`}
>
<td className="border-b border-slate-100 px-4 py-3 dark:border-slate-800">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleRow(v.id)}
aria-label={`Select ${v.name}`}
className={`h-4 w-4 cursor-pointer rounded border-slate-300 text-indigo-600 accent-indigo-600 dark:border-slate-600 ${focusRing}`}
/>
</td>
<td className="whitespace-nowrap border-b border-slate-100 px-4 py-3 font-medium text-slate-900 dark:border-slate-800 dark:text-white">
{v.name}
</td>
<td className="whitespace-nowrap border-b border-slate-100 px-4 py-3 text-slate-600 dark:border-slate-800 dark:text-slate-300">
{v.category}
</td>
<td className="whitespace-nowrap border-b border-slate-100 px-4 py-3 text-slate-600 dark:border-slate-800 dark:text-slate-300">
{v.owner}
</td>
<td className="border-b border-slate-100 px-4 py-3 text-right tabular-nums text-slate-700 dark:border-slate-800 dark:text-slate-200">
{v.seats}
</td>
<td className="border-b border-slate-100 px-4 py-3 text-right font-medium tabular-nums text-slate-900 dark:border-slate-800 dark:text-white">
{usd(v.monthly)}
</td>
<td className="whitespace-nowrap border-b border-slate-100 px-4 py-3 text-slate-600 dark:border-slate-800 dark:text-slate-300">
{fmtDate(v.renewal)}
</td>
<td className="whitespace-nowrap border-b border-slate-100 px-4 py-3 dark:border-slate-800">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset ${meta.badge}`}
>
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${meta.dot} ${
v.status === "churning" ? "stickytbl-dot" : ""
}`}
/>
{meta.label}
</span>
</td>
</tr>
);
})}
{visibleRows.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 vendors match your filters
</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Try clearing the search or switching back to “All”.
</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Summary / spend meter */}
<div className="mt-4 flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-slate-600 dark:text-slate-400" aria-live="polite">
<span className="font-semibold text-slate-900 dark:text-white">{selectedVisibleCount}</span>{" "}
of {visibleRows.length} shown selected
<span className="mx-2 text-slate-300 dark:text-slate-700">•</span>
<span className="font-semibold text-slate-900 dark:text-white">{usd(selectedMonthly)}</span>{" "}
of {usd(totalMonthly)} / mo
</div>
<div className="flex items-center gap-3">
<div
className="h-2 w-40 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
role="progressbar"
aria-label="Selected spend as share of visible total"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={meterPct}
>
<motion.div
className="h-full rounded-full bg-indigo-600 dark:bg-indigo-500"
initial={false}
animate={{ width: `${meterPct}%` }}
transition={prefersReduced ? { duration: 0 } : { type: "spring", stiffness: 220, damping: 30 }}
/>
</div>
<span className="w-10 text-right text-sm font-semibold tabular-nums text-slate-900 dark:text-white">
{meterPct}%
</span>
</div>
</div>
</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

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

