Hover detail The exact value at one moment, without leaving the chart. A shared crosshair reading every series at the hovered timestamp is worth more than a per-series tooltip, because the comparison is usually the question. npm i recharts Tokens this needs: --foreground, --card, --popover, --popover-foreground, --muted-foreground, --border, --status-critical, --chart-1, --chart-2, --chart-3, --chart-4 The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // HoverDetail.tsx ──────────────────────────────────────────────────────────────────────── import { useState, type KeyboardEvent } from "react"; import { Line, LineChart, ReferenceDot, ReferenceLine, XAxis, YAxis } from "recharts"; import { cn } from "@/lib/utils"; export type Series = { key: string; label: string; color: string }; export type Panel = { title: string; unit: string; series: Series[] }; /** One row per sample; `t` is epoch ms and every series key is a column. */ export type Row = { t: number } & Record; /** Full precision with the unit, never the axis's rounded version. */ const fmt = (v: number, unit: string) => `${v}${unit}`; const hhmmss = (t: number) => new Date(t).toISOString().slice(11, 19); /** * One crosshair through every panel and one readout for the instant under it. * The hovered index is the only state, so all the panels agree on the * timestamp by construction, and the readout is a list of exact values sorted * by size rather than by name. */ export function HoverDetail({ panels, data, defaultIndex = 0, width = 340, height = 64 }: { panels: Panel[]; data: Row[]; /** Which sample the crosshair starts on. */ defaultIndex?: number; width?: number; height?: number; }) { const [i, setI] = useState(defaultIndex); const row = data[i]; const move = (e: { activeTooltipIndex?: number }) => { if (typeof e?.activeTooltipIndex === "number") setI(e.activeTooltipIndex); }; // The keyboard equivalent: arrows walk the samples. const key = (e: KeyboardEvent) => { if (e.key === "ArrowLeft") setI((n) => Math.max(0, n - 1)); if (e.key === "ArrowRight") setI((n) => Math.min(data.length - 1, n + 1)); }; return (
{panels.map((p) => (

{p.title}

{p.series.map((s) => ( ))} {p.series.map((s) => ( ))}
))}

{hhmmss(row.t)} UTC

{panels.map((p, n) => (
    0 && "border-t")}> {[...p.series].sort((a, b) => row[b.key] - row[a.key]).map((s) => (
  • {s.label} {fmt(row[s.key], p.unit)}
  • ))}
))}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { HoverDetail, type Panel, type Row } from "./HoverDetail"; /** Forty seconds at five-second resolution. The crosshair starts on 14:07:20. */ const T0 = Date.UTC(2026, 8, 15, 14, 7, 0); const cols = { checkout: [180, 236, 210, 356, 412, 268, 302, 290], search: [96, 130, 112, 240, 308, 160, 200, 240], cart: [120, 130, 125, 170, 191, 140, 150, 145], user: [60, 70, 64, 88, 96, 72, 80, 76], rss: [640, 720, 760, 1240, 1280, 920, 840, 880], errors: [2, 4, 6, 30, 34, 12, 8, 6], }; const DATA: Row[] = cols.checkout.map((_, i) => ({ t: T0 + i * 5_000, ...Object.fromEntries(Object.entries(cols).map(([k, v]) => [k, v[i]])), })); const PANELS: Panel[] = [ { title: "Latency", unit: "ms", series: [ { key: "checkout", label: "checkout", color: "hsl(var(--chart-1))" }, { key: "search", label: "search", color: "hsl(var(--chart-2))" }, { key: "cart", label: "cart", color: "hsl(var(--chart-3))" }, { key: "user", label: "user", color: "hsl(var(--chart-4))" }, ], }, { title: "Memory", unit: "MB", series: [{ key: "rss", label: "rss", color: "hsl(var(--chart-2))" }] }, { title: "Errors", unit: "/s", series: [{ key: "errors", label: "5xx", color: "hsl(var(--status-critical))" }] }, ]; export default function Demo() { return ; }