Skip to content
KONIGI Patterns

Dashboards / Interaction / Hover detail

‹ 4 of 8 ›

Hover detail

People need exact values for a point without leaving the chart.

Updated September 10, 2026

Problem

A chart shows the overall shape, but people sometimes need an exact value and timestamp without leaving it.

Solution

Show the value, unit, time, and series name for the selected point. Keep the detail compact and position it so it does not hide the area being inspected.

For comparisons, offer values from relevant series at the same time. A shared crosshair can align that position across related charts.

Provide keyboard and touch access to the same information. Use a click or separate panel for longer explanations and actions that need to remain open.

Use when

People usually read a chart for its shape but occasionally need exact values.

Don’t use when

The precise value is the primary need. If viewers hover every time, they wanted a table. And never put anything essential behind hover alone, because hover does not exist on touch and does not exist for keyboard navigation.

Trade-offs

Hover controls can be hard to discover and are unavailable on touch screens. Large tooltips obscure data, and cursor-following panels can flicker near edges. Important information needs another accessible path.

Checklist

  • Does the tooltip show one series or all of them, and can the viewer choose?
  • If all, are they sorted by value rather than alphabetically?
  • Is there a shared crosshair across panels, and does it align to the same timestamp?
  • Are units and full precision shown, rather than the axis’s rounded version?
  • Is the timestamp in a stated timezone?
  • Does the tooltip avoid covering the point it describes?
  • What is the touch equivalent, and does it exist?
  • Is any of this reachable by keyboard?
  • Does the tooltip stay legible at twelve series, and what happens at fifty?
  • Is anything only available on hover that a viewer would need to find first?

Compare

Grafana treats the tooltip as a panel option with single, all and hidden modes plus a shared crosshair set at dashboard level, so the coordination is a deliberate setting rather than a default. Datadog links hover across a whole page by default, which makes correlation the path of least resistance and is the main reason its dashboards feel like one instrument. Honeycomb puts less weight on hover because the heatmap’s answer comes from selecting a region rather than reading a point, so the pointer’s job is drawing rather than inspecting. Netdata synchronises the crosshair across every chart on the page, which at per-second resolution across dozens of charts is what makes the density usable rather than overwhelming.

Time series often uses point details. Detail on demand expands a whole panel. Legend and series toggle identifies or isolates lines.

Mobile adaptation supplies touch interactions. Explain this metric provides definitions that may need more room than a tooltip.

Hover detail anatomy Three stacked charts with one crosshair drawn through all of them at the same instant, and a tooltip listing every series at that timestamp sorted by value rather than by name. One crosshair, three panels Latency Memory Errors 14:07:20 checkout 412ms search 308ms cart 191ms user 96ms 1 2 1 SHARED CROSSHAIR Turns a page of separate charts into one coordinated instrument. "Did memory spike at the same moment" stops needing a ruler. 2 SORTED BY VALUE Not by series name. A small change that makes a twelve-line tooltip readable. The exact value, the timestamp, the units, the series. Not a paragraph, not an action.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

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
recharts
Tokens
--foreground--card--popover--popover-foreground--muted-foreground--border--status-critical--chart-1--chart-2--chart-3--chart-4

Latency

Memory

Errors

14:07:20 UTC

  • checkout412ms
  • search308ms
  • cart191ms
  • user96ms
  • rss1280MB
  • 5xx34/s

HoverDetail.tsxOne hovered index drives a crosshair through every panel and a readout sorted by value, with units and a stated timezone. Arrow keys move it.

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<string, number>;

/** 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 (
    <div className="grid grid-cols-[1.4fr_1fr] gap-5" tabIndex={0} onKeyDown={key} aria-label="hover detail; arrow keys move the crosshair">
      <div className="flex flex-col gap-2 rounded-lg border bg-card p-4">
        {panels.map((p) => (
          <div key={p.title}>
            <p className="text-[11px] uppercase tracking-wide text-muted-foreground">{p.title}</p>
            <div className="overflow-x-auto">
              <LineChart width={width} height={height} data={data} margin={{ top: 6, right: 4, bottom: 2, left: 4 }} onMouseMove={move}>
                <XAxis interval={0} dataKey="t" type="number" domain={["dataMin", "dataMax"]} hide />
                <YAxis hide domain={[0, "auto"]} />
                {p.series.map((s) => (
                  <Line key={s.key} type="linear" dataKey={s.key} stroke={s.color} strokeWidth={2} dot={false} isAnimationActive={false} />
                ))}
                <ReferenceLine x={row.t} stroke="hsl(var(--foreground))" />
                {p.series.map((s) => (
                  <ReferenceDot key={s.key} x={row.t} y={row[s.key]} r={3} fill={s.color} stroke="none" />
                ))}
              </LineChart>
            </div>
          </div>
        ))}
      </div>

      <div className="self-start rounded-lg border bg-popover p-3" role="status" aria-live="polite">
        <p className="border-b pb-2 text-[11px] tabular-nums text-popover-foreground">{hhmmss(row.t)} UTC</p>
        {panels.map((p, n) => (
          <ul key={p.title} className={cn("text-[11px] tabular-nums", n > 0 && "border-t")}>
            {[...p.series].sort((a, b) => row[b.key] - row[a.key]).map((s) => (
              <li key={s.key} className="flex justify-between py-1">
                <span className="inline-flex items-center gap-1.5 text-popover-foreground">
                  <span className="size-2 rounded-[1px]" style={{ background: s.color }} aria-hidden />
                  {s.label}
                </span>
                <span className="text-popover-foreground">{fmt(row[s.key], p.unit)}</span>
              </li>
            ))}
          </ul>
        ))}
      </div>
    </div>
  );
}

demo.tsxHow it is called: three panels over one set of rows, starting on the sample the drawing points at.

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 <HoverDetail panels={PANELS} data={DATA} defaultIndex={4} />;
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Screenshots are grouped by product and include the capture date. Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.

Tableau Public

Published workbooks show how individual authors arrange charts and interactions in Tableau.

Shopify Customer Journey September 10, 2026 Tableau Public embed view; workbook published by Lovelytics medium · light · desktop-web
This Lovelytics workbook uses clickable metrics and bars to filter or reveal detail. Instructions explain these interactions because the marks do not clearly look interactive. Nine summary values compete for attention in a grid. The sales chart also has a mismatch between its monthly title and the displayed dates, while its truncated vertical scale emphasizes variation. These choices make labels and interaction cues especially important.
  • Cross-filter An instruction explains that metric tiles filter the dashboard.
  • Header KPI strip Nine values share a grid without a clear starting priority.
  • Time series The title and displayed period differ, and the vertical axis starts above zero.
  • Hover detail City details are described as available on hover.
  • Geo map with markers The map shows raw sales totals, which also reflect differences between populations.
  • Ranked list One much larger value leaves the remaining bars difficult to compare.