Closest point tooltip


Sometimes the tooltip shouldn't wait for the user to hover an exact marker. It should follow the cursor and display the closest data point at any x. The canonical use case is a line chart.

Same pattern as the previous lesson, with one new trick: finding the nearest point efficiently using d3.bisector.

Members only
12 minutes read

What we're building

On the line chart below, the tooltip doesn't wait for you to land on a specific dot.

Hover anywhere inside the chart and it snaps to the closest data point. Move the cursor and it follows.

import { useState } from "react";
import { scaleLinear, line, bisector } from "d3";
import { Tooltip } from "./Tooltip";
import "./styles.css";

const width = 500;
const height = 300;

const data = [
  { x: 0, y: 45 },
  { x: 1, y: 52 },
  { x: 2, y: 48 },
  { x: 3, y: 60 },
  { x: 4, y: 55 },
  { x: 5, y: 70 },
  { x: 6, y: 68 },
  { x: 7, y: 82 },
  { x: 8, y: 78 },
  { x: 9, y: 90 },
];

const xScale = scaleLinear().domain([0, 9]).range([0, width]);
const yScale = scaleLinear().domain([0, 100]).range([height, 0]);

const lineGenerator = line()
  .x((d) => xScale(d.x))
  .y((d) => yScale(d.y));

const bisect = bisector((d) => d.x).left;

export default function App() {
  const [interactionData, setInteractionData] = useState(null);

  const handleMouseMove = (event) => {
    const cursorX = event.nativeEvent.offsetX;
    const xValue = xScale.invert(cursorX);

    const index = bisect(data, xValue);
    const d0 = data[index - 1];
    const d1 = data[index];
    const nearest =
      !d0 ? d1 :
      !d1 ? d0 :
      xValue - d0.x > d1.x - xValue ? d1 : d0;

    setInteractionData({
      xPos: xScale(nearest.x),
      yPos: yScale(nearest.y),
      name: "Day " + nearest.x,
      xValue: nearest.x,
      yValue: nearest.y,
      color: "#4e79a7",
    });
  };

  return (
    <div style={{ position: "relative" }}>
      <svg width={width} height={height}>
        {/* Line */}
        <path
          d={lineGenerator(data)}
          stroke="#4e79a7"
          strokeWidth={2}
          fill="none"
        />

        {/* Data points */}
        {data.map((d) => (
          <circle
            key={d.x}
            cx={xScale(d.x)}
            cy={yScale(d.y)}
            r={interactionData && interactionData.xValue === d.x ? 6 : 3}
            fill="#4e79a7"
          />
        ))}

        {/* Invisible cursor catcher */}
        <rect
          width={width}
          height={height}
          fill="transparent"
          onMouseMove={handleMouseMove}
          onMouseLeave={() => setInteractionData(null)}
        />
      </svg>

      {/* Tooltip layer */}
      <div
        style={{
          position: "absolute",
          width,
          height,
          top: 0,
          left: 0,
          pointerEvents: "none",
        }}
      >
        <Tooltip interactionData={interactionData} />
      </div>
    </div>
  );
}

10 data points, one tooltip that tracks the nearest point.

The pattern, barely changed

Good news: we're not starting over. The setup from the previous lesson is almost entirely reused:

  • Same useState holding an interactionData object.
  • Same Tooltip component. Exactly the same file.
  • Same absolute-positioned overlay div with pointerEvents: "none" on top of the SVG.

Two small things change. Instead of attaching onMouseEnter on every marker, we put onMouseMove on a single element covering the whole plot area. And on every move, we need to figure out which data point is closest to the cursor.

The 3 new moves

1️⃣ Catch the cursor everywhere

Inside the SVG, render a transparent rect covering the whole plot area after all the shapes, so it sits on top. That rect gets the onMouseMove handler.

<svg width={width} height={height}>
    <path d={lineGenerator(data)} stroke="#4e79a7" fill="none" />
    {data.map((d) => (
      <circle key={d.x} cx={xScale(d.x)} cy={yScale(d.y)} r={3} />
    ))}

    {/* Invisible catcher, on top of everything */}
    <rect
      width={width}
      height={height}
      fill="transparent"
      onMouseMove={handleMouseMove}
      onMouseLeave={() => setInteractionData(null)}
    />
  </svg>

⚠️ The rect must be the last child of the SVG (painted last = on top) so the cursor always hits it, no matter which line or dot is underneath.

Oh no! 😱

It seems like you haven't enrolled in the course yet!

Join many other students today and learn how to create bespoke, interactive graphs with d3.js and React!

Enrollment is currently closed. Join the waitlist to be notified when doors reopen:

Or Login