Web InnoventixFreeCode

Sticky Header Table

Original · free

sticky header scroll table

byWeb InnoventixReact + Tailwind
tablestickyheadertables
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-sticky-header.json
table-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 quote

Similar components

Browse all →