Web InnoventixFreeCode

Hover Table

Original · free

row-hover highlight table

byWeb InnoventixReact + Tailwind
tablehovertables
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-hover.json
table-hover.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

type StatusKey = "healthy" | "degraded" | "maintenance" | "down";
type SortKey = "service" | "region" | "latency" | "uptime" | "requests" | "status";
type SortDir = "asc" | "desc";

interface ServiceRow {
  id: string;
  service: string;
  region: string;
  latency: number;
  uptime: number;
  requests: number;
  status: StatusKey;
}

const ROWS: ServiceRow[] = [
  { id: "auth-gw", service: "Auth Gateway", region: "us-east-1", latency: 68, uptime: 99.98, requests: 128400, status: "healthy" },
  { id: "media-cdn", service: "Media CDN", region: "us-west-2", latency: 34, uptime: 99.99, requests: 342700, status: "healthy" },
  { id: "payments", service: "Payments API", region: "eu-west-2", latency: 142, uptime: 99.94, requests: 84200, status: "degraded" },
  { id: "search", service: "Search Index", region: "ap-south-1", latency: 210, uptime: 99.71, requests: 51900, status: "degraded" },
  { id: "webhooks", service: "Webhooks Dispatcher", region: "eu-central-1", latency: 96, uptime: 99.9, requests: 27600, status: "healthy" },
  { id: "profiles", service: "User Profiles", region: "eu-west-2", latency: 54, uptime: 99.97, requests: 96800, status: "healthy" },
  { id: "notify", service: "Notification Bus", region: "ap-southeast-1", latency: 178, uptime: 99.6, requests: 45100, status: "degraded" },
  { id: "analytics", service: "Analytics Pipeline", region: "us-east-1", latency: 512, uptime: 98.2, requests: 19300, status: "maintenance" },
  { id: "backup", service: "Backup Orchestrator", region: "us-west-2", latency: 88, uptime: 97.4, requests: 8200, status: "down" },
];

const COLUMNS: { key: SortKey; label: string; align: "left" | "right" }[] = [
  { key: "service", label: "Service", align: "left" },
  { key: "region", label: "Region", align: "left" },
  { key: "latency", label: "Latency", align: "right" },
  { key: "uptime", label: "Uptime", align: "right" },
  { key: "requests", label: "Req / min", align: "right" },
  { key: "status", label: "Status", align: "left" },
];

const NUMERIC_KEYS = new Set<SortKey>(["latency", "uptime", "requests", "status"]);

const STATUS_RANK: Record<StatusKey, number> = { down: 3, degraded: 2, maintenance: 1, healthy: 0 };

const STATUS_META: Record<StatusKey, { label: string; badge: string; dot: string; pulse: boolean }> = {
  healthy: {
    label: "Healthy",
    badge: "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300 ring-emerald-600/20 dark:ring-emerald-400/25",
    dot: "bg-emerald-500 dark:bg-emerald-400",
    pulse: false,
  },
  degraded: {
    label: "Degraded",
    badge: "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-300 ring-amber-600/20 dark:ring-amber-400/25",
    dot: "bg-amber-500 dark:bg-amber-400",
    pulse: false,
  },
  maintenance: {
    label: "Maintenance",
    badge: "bg-sky-100 text-sky-800 dark:bg-sky-500/15 dark:text-sky-300 ring-sky-600/20 dark:ring-sky-400/25",
    dot: "bg-sky-500 dark:bg-sky-400",
    pulse: false,
  },
  down: {
    label: "Down",
    badge: "bg-rose-100 text-rose-800 dark:bg-rose-500/15 dark:text-rose-300 ring-rose-600/20 dark:ring-rose-400/25",
    dot: "bg-rose-500 dark:bg-rose-400",
    pulse: true,
  },
};

const MONOGRAMS = [
  "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
  "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300",
  "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
  "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
  "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
  "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
];

function initials(name: string): string {
  return name
    .split(/\s+/)
    .slice(0, 2)
    .map((w) => w.charAt(0))
    .join("")
    .toUpperCase();
}

function uptimeClass(uptime: number): string {
  if (uptime >= 99.9) return "text-emerald-700 dark:text-emerald-300";
  if (uptime >= 99) return "text-amber-700 dark:text-amber-300";
  return "text-rose-700 dark:text-rose-300";
}

function ArrowIcon({ dir, active }: { dir: SortDir; active: boolean }) {
  return (
    <svg
      viewBox="0 0 16 16"
      aria-hidden="true"
      className={`h-3.5 w-3.5 shrink-0 transition-all duration-200 ${
        active ? "opacity-100" : "opacity-0 group-hover/th:opacity-40"
      } ${active && dir === "desc" ? "rotate-180" : "rotate-0"}`}
    >
      <path
        d="M8 3.5v9M4.5 9 8 12.5 11.5 9"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export default function TableHover() {
  const reduce = useReducedMotion();

  const [sortKey, setSortKey] = useState<SortKey>("requests");
  const [sortDir, setSortDir] = useState<SortDir>("desc");
  const [selected, setSelected] = useState<Set<string>>(() => new Set());
  const [hoverId, setHoverId] = useState<string | null>(null);
  const [focusId, setFocusId] = useState<string | null>(null);

  const selectAllRef = useRef<HTMLInputElement | null>(null);

  const activeId = hoverId ?? focusId;

  const sorted = useMemo(() => {
    const arr = [...ROWS];
    const dir = sortDir === "asc" ? 1 : -1;
    arr.sort((a, b) => {
      let cmp = 0;
      switch (sortKey) {
        case "service":
          cmp = a.service.localeCompare(b.service);
          break;
        case "region":
          cmp = a.region.localeCompare(b.region);
          break;
        case "latency":
          cmp = a.latency - b.latency;
          break;
        case "uptime":
          cmp = a.uptime - b.uptime;
          break;
        case "requests":
          cmp = a.requests - b.requests;
          break;
        case "status":
          cmp = STATUS_RANK[a.status] - STATUS_RANK[b.status];
          break;
      }
      if (cmp === 0) cmp = a.service.localeCompare(b.service);
      return cmp * dir;
    });
    return arr;
  }, [sortKey, sortDir]);

  const allSelected = selected.size === ROWS.length;
  const someSelected = selected.size > 0 && !allSelected;

  useEffect(() => {
    if (selectAllRef.current) selectAllRef.current.indeterminate = someSelected;
  }, [someSelected]);

  function requestSort(key: SortKey) {
    if (key === sortKey) {
      setSortDir((d) => (d === "asc" ? "desc" : "asc"));
    } else {
      setSortKey(key);
      setSortDir(NUMERIC_KEYS.has(key) ? "desc" : "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) => (prev.size === ROWS.length ? new Set() : new Set(ROWS.map((r) => r.id))));
  }

  const selectedRows = ROWS.filter((r) => selected.has(r.id));
  const avgLatency =
    selectedRows.length > 0
      ? Math.round(selectedRows.reduce((sum, r) => sum + r.latency, 0) / selectedRows.length)
      : 0;

  const checkboxClass =
    "h-4 w-4 shrink-0 cursor-pointer rounded border-slate-300 bg-white text-indigo-600 accent-indigo-600 dark:border-slate-600 dark:bg-slate-800 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 sm:px-6 sm:py-20 lg:px-8 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes thv-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.82); } }
        @keyframes thv-rowin { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
        .thv-pulse { animation: thv-pulse 1.6s ease-in-out infinite; }
        .thv-rowin { animation: thv-rowin 0.4s ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .thv-pulse, .thv-rowin { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-5xl">
        <header className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
          <div>
            <div className="mb-2 inline-flex items-center gap-2 rounded-full bg-white px-3 py-1 text-xs font-medium text-slate-600 ring-1 ring-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:ring-slate-800">
              <span className="relative flex h-2 w-2">
                <span className="thv-pulse absolute inline-flex h-full w-full rounded-full bg-emerald-400/70" />
                <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
              </span>
              Live · updated 12s ago
            </div>
            <h2 className="text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
              Service health
            </h2>
            <p className="mt-1 max-w-xl text-sm text-slate-600 dark:text-slate-400">
              Nine production services across four regions. Hover or focus a row to trace it, sort any
              column, and select the ones you want to page on-call about.
            </p>
          </div>

          <ul className="flex flex-wrap items-center gap-x-4 gap-y-2" aria-label="Status legend">
            {(Object.keys(STATUS_META) as StatusKey[]).map((key) => (
              <li key={key} className="flex items-center gap-1.5 text-xs text-slate-600 dark:text-slate-400">
                <span className={`h-2 w-2 rounded-full ${STATUS_META[key].dot}`} />
                {STATUS_META[key].label}
              </li>
            ))}
          </ul>
        </header>

        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          <div className="max-h-[520px] overflow-auto">
            <table className="w-full border-collapse text-left text-sm">
              <caption className="sr-only">
                Service health metrics. Columns are sortable buttons; rows can be selected with checkboxes.
              </caption>
              <thead>
                <tr className="sticky top-0 z-10">
                  <th
                    scope="col"
                    className="w-12 border-b border-slate-200 bg-slate-50/95 px-4 py-3 backdrop-blur dark:border-slate-800 dark:bg-slate-900/95"
                  >
                    <input
                      ref={selectAllRef}
                      type="checkbox"
                      className={checkboxClass}
                      checked={allSelected}
                      onChange={toggleAll}
                      aria-label="Select all services"
                    />
                  </th>
                  {COLUMNS.map((col) => {
                    const isActive = sortKey === col.key;
                    return (
                      <th
                        key={col.key}
                        scope="col"
                        aria-sort={isActive ? (sortDir === "asc" ? "ascending" : "descending") : "none"}
                        className={`border-b border-slate-200 bg-slate-50/95 px-4 py-3 font-medium backdrop-blur dark:border-slate-800 dark:bg-slate-900/95 ${
                          col.align === "right" ? "text-right" : "text-left"
                        }`}
                      >
                        <button
                          type="button"
                          onClick={() => requestSort(col.key)}
                          className={`group/th inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs font-semibold uppercase tracking-wide 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-50 dark:focus-visible:ring-offset-slate-900 ${
                            col.align === "right" ? "flex-row-reverse" : "flex-row"
                          } ${
                            isActive
                              ? "text-indigo-700 dark:text-indigo-300"
                              : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
                          }`}
                        >
                          <span>{col.label}</span>
                          <ArrowIcon dir={sortDir} active={isActive} />
                        </button>
                      </th>
                    );
                  })}
                </tr>
              </thead>
              <tbody>
                {sorted.map((row, index) => {
                  const isActive = activeId === row.id;
                  const isSelected = selected.has(row.id);
                  const meta = STATUS_META[row.status];
                  return (
                    <tr
                      key={row.id}
                      onMouseEnter={() => setHoverId(row.id)}
                      onMouseLeave={() => setHoverId((cur) => (cur === row.id ? null : cur))}
                      onFocusCapture={() => setFocusId(row.id)}
                      onBlurCapture={() => setFocusId((cur) => (cur === row.id ? null : cur))}
                      className={`thv-rowin border-b border-slate-100 transition-colors last:border-b-0 dark:border-slate-800/70 ${
                        isActive
                          ? "bg-indigo-50/80 dark:bg-indigo-500/10"
                          : isSelected
                            ? "bg-slate-50 dark:bg-white/[0.03]"
                            : "bg-transparent"
                      }`}
                    >
                      <td className="relative px-4 py-3 align-middle">
                        <motion.span
                          aria-hidden="true"
                          className="absolute inset-y-1 left-0 w-[3px] rounded-r-full bg-indigo-500 dark:bg-indigo-400"
                          initial={false}
                          animate={{ scaleY: isActive ? 1 : 0, opacity: isActive ? 1 : 0 }}
                          transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 440, damping: 34 }}
                          style={{ transformOrigin: "center" }}
                        />
                        <input
                          type="checkbox"
                          className={checkboxClass}
                          checked={isSelected}
                          onChange={() => toggleRow(row.id)}
                          aria-label={`Select ${row.service}`}
                        />
                      </td>

                      <th scope="row" className="px-4 py-3 text-left font-normal">
                        <div className="flex items-center gap-3">
                          <span
                            aria-hidden="true"
                            className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg text-xs font-semibold ${
                              MONOGRAMS[index % MONOGRAMS.length]
                            }`}
                          >
                            {initials(row.service)}
                          </span>
                          <span className="font-medium text-slate-900 dark:text-slate-100">{row.service}</span>
                        </div>
                      </th>

                      <td className="px-4 py-3 text-left">
                        <span className="rounded-md bg-slate-100 px-2 py-0.5 font-mono text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">
                          {row.region}
                        </span>
                      </td>

                      <td className="px-4 py-3 text-right tabular-nums text-slate-700 dark:text-slate-300">
                        {row.latency}
                        <span className="ml-0.5 text-xs text-slate-400 dark:text-slate-500">ms</span>
                      </td>

                      <td className={`px-4 py-3 text-right font-medium tabular-nums ${uptimeClass(row.uptime)}`}>
                        {row.uptime.toFixed(2)}
                        <span className="ml-0.5 text-xs opacity-70">%</span>
                      </td>

                      <td className="px-4 py-3 text-right tabular-nums text-slate-700 dark:text-slate-300">
                        {row.requests.toLocaleString("en-US")}
                      </td>

                      <td className="px-4 py-3 text-left">
                        <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={`h-1.5 w-1.5 rounded-full ${meta.dot} ${meta.pulse ? "thv-pulse" : ""}`} />
                          {meta.label}
                        </span>
                      </td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>

          <div className="flex flex-col gap-3 border-t border-slate-200 bg-slate-50/60 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between dark:border-slate-800 dark:bg-slate-900/60">
            <p aria-live="polite" className="text-slate-600 dark:text-slate-400">
              <span className="font-semibold text-slate-900 dark:text-slate-100">{selected.size}</span> of{" "}
              {ROWS.length} services selected
              {selected.size > 0 && (
                <span className="text-slate-500 dark:text-slate-500">
                  {" "}
                  · avg latency <span className="font-medium text-slate-700 dark:text-slate-300">{avgLatency}ms</span>
                </span>
              )}
            </p>
            <button
              type="button"
              onClick={() => setSelected(new Set())}
              disabled={selected.size === 0}
              className="inline-flex items-center justify-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 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 disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
            >
              <svg viewBox="0 0 16 16" aria-hidden="true" className="h-3.5 w-3.5">
                <path
                  d="M4 4l8 8M12 4l-8 8"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.6"
                  strokeLinecap="round"
                />
              </svg>
              Clear selection
            </button>
          </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 →