Hover Table
Original · freerow-hover highlight table
byWeb InnoventixReact + Tailwind
tablehovertables
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.jsontable-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 quoteSimilar components
Browse all →
Basic Table
Originalclean basic data table

Striped Table
Originalstriped-row table

Bordered Table
Originalbordered 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

Status Badges Table
Originaltable with status badges

Compact Table
Originaldense compact table

