Reactで、折れ線グラフに乗せると値が出るツールチップを作る方法
折れ線グラフのツールチップは、ポインターの横の位置から「いちばん近い点」を求めれば作れます。気を付けるのは、画面の px と SVG の viewBox の座標が、グラフを縮めると一致しなくなることです。getScreenCTM() で座標を直してから点を選べば、スマートフォンの幅でも、乗せた点の値が出ます。
ライブラリを使わずに、LineChart というコンポーネントを作りました。グラフの上でポインターを動かすと、いちばん近い月に縦の点線と丸が付き、その月の値がツールチップに出ます。データは架空の数字です。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。タッチは Playwright のタッチ端末の設定と、Chrome DevTools Protocol で送ったタッチ操作で試したもので、実機のスマートフォンでは試していません。Firefox と Safari、ペン入力、スクリーンリーダーでの読み上げも試していません。点が等間隔に並んでいないデータ(日付が飛び飛びの時系列など)は扱っていません。

ポインターの位置を、viewBox の座標に直す
座標の計算は、1つのファイルにまとめました。点の x は、左の余白から右の余白までを、点の数より1つ少ない数で等分した位置です。y は、値が 0 なら下の端、max なら上の端になるように割り当てます。
export type Point = { label: string; value: number };
// グラフの大きさ(viewBox の単位)と、軸の文字のための余白
export const W = 640;
export const H = 300;
export const PAD = { top: 16, right: 16, bottom: 32, left: 48 };
export const PLOT_W = W - PAD.left - PAD.right;
export const PLOT_H = H - PAD.top - PAD.bottom;
// i 番目の点の x と、値 v の y(どちらも viewBox の単位)
export const xAt = (i: number, n: number) => PAD.left + (n > 1 ? (PLOT_W * i) / (n - 1) : PLOT_W / 2);
export const yAt = (v: number, max: number) => PAD.top + PLOT_H * (1 - v / max);
// 画面上のポインターの位置(clientX, clientY)を、viewBox の座標に直す
export function toSvgPoint(svg: SVGSVGElement, clientX: number, clientY: number): DOMPoint | null {
const m = svg.getScreenCTM();
if (!m) return null;
return new DOMPoint(clientX, clientY).matrixTransform(m.inverse());
}
// viewBox の x に、いちばん近い点の番号(点は等間隔に並んでいる)
export function nearestIndex(x: number, n: number): number {
if (n <= 1) return 0;
const i = Math.round(((x - PAD.left) / PLOT_W) * (n - 1));
return Math.min(n - 1, Math.max(0, i));
}
toSvgPoint の中で使っている getScreenCTM() は、SVG 2 の仕様の getScreenCTM の項によると、要素の座標を、ブラウザの表示領域(ビューポート)の座標に変える行列を返します。この行列には viewBox による拡大・縮小や、ページの中での <svg> の位置も含まれます。名前には Screen と付いていますが、基準はポインターの clientX・clientY と同じビューポートで、仕様の注記にも getClientCTM と名付けた方が適切だったと書かれています。逆行列(inverse())を、ポインターの clientX・clientY の点に matrixTransform() で掛けると、viewBox の座標に戻せます。
offsetX を使っても、<svg> そのものの上で受け取れば、<svg> の左端からの距離は分かります。ただし、単位は画面の px です。viewBox の幅は 640 なので、グラフが 640px で表示されているうちは、px と viewBox の座標が同じになり、違いに気付きません。
幅 375px の画面では、グラフの幅が 343px になりました。カーソルを各月の x に乗せて比べると、getScreenCTM() で直した場合はどれも正しい月が出ました。offsetX をそのまま viewBox の座標として使った場合は、次のようにずれました。
| 乗せた月 | offsetX | offsetX をそのまま使ったときに出る月 |
|---|---|---|
| 4月 | 109.9 | 2月 |
| 9月 | 250.2 | 5月 |
| 12月 | 334.4 | 6月 |

画面の px は viewBox の座標より小さい数になるので、右へ行くほど、乗せた月より左の月が出ます。12月に乗せても6月までしか届きません。
いちばん近い点を選ぶ
nearestIndex は、viewBox の x を「何番目の点か」の小数に直し、Math.round で四捨五入しています。点と点の真ん中を境に、近い方の点が選ばれます。幅 800px の画面で、3月と4月のちょうど真ん中から viewBox の単位で 2 だけ左に乗せると「3月 410人」、2 だけ右に乗せると「4月 460人」が出ました。
カーソルを左右の余白に乗せたときは、番号が 0 より小さくなったり、点の数以上になったりしないように、Math.min と Math.max で端の点に収めています。グラフの左の余白(x が 10 の位置)に乗せると、1月の点に丸が付き、その中心は (48, 167.2) でした。1月の値 320 の位置です。
縦の位置は見ていないので、点の真上でなくても、x が同じなら、どの高さに乗せても同じ点が選ばれます。6月の x で、横軸の文字の高さに乗せたときも「6月 350人」が出ました。折れ線グラフでは、線のすぐそばを狙わなくても値が読めるので、この方が扱いやすいと筆者は考えています。この計算は、点が等間隔に並んでいることを前提にしています。
LineChart のコード
ポインターの処理、キーボードの処理、ツールチップをまとめたコンポーネントです。
import { useState, type KeyboardEvent, type PointerEvent } from 'react';
import { H, PAD, PLOT_H, W, nearestIndex, toSvgPoint, xAt, yAt, type Point } from './chart';
type Props = {
data: Point[];
title: string;
max: number; // 縦軸のいちばん上の値
unit?: string;
};
export function LineChart({ data, title, max, unit = '' }: Props) {
const [active, setActive] = useState<number | null>(null);
const n = data.length;
const points = data.map((d, i) => `${xAt(i, n)},${yAt(d.value, max)}`).join(' ');
const pick = (e: PointerEvent<SVGSVGElement>) => {
const p = toSvgPoint(e.currentTarget, e.clientX, e.clientY);
if (p) setActive(nearestIndex(p.x, n));
};
// 指を離したときにも pointerleave が来るので、マウスのときだけ消す
const leave = (e: PointerEvent<SVGSVGElement>) => {
if (e.pointerType === 'mouse') setActive(null);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const next: Record<string, number> = {
ArrowRight: active === null ? 0 : Math.min(n - 1, active + 1),
ArrowLeft: active === null ? n - 1 : Math.max(0, active - 1),
Home: 0,
End: n - 1,
};
if (e.key in next) {
e.preventDefault();
setActive(next[e.key]);
} else if (e.key === 'Escape') {
setActive(null);
}
};
const sel = active === null ? null : data[active];
const selX = active === null ? 0 : xAt(active, n);
const text = sel ? `${sel.label} ${sel.value}${unit}` : '';
return (
<div
className="line-chart"
tabIndex={0}
role="group"
aria-label={`${title}(左右の矢印キーで値を読む)`}
onKeyDown={onKeyDown}
onBlur={() => setActive(null)}
>
<svg
viewBox={`0 0 ${W} ${H}`}
aria-hidden="true"
onPointerMove={pick}
onPointerDown={pick}
onPointerLeave={leave}
>
{[0, 0.5, 1].map((t) => (
<g key={t}>
<line x1={PAD.left} x2={W - PAD.right} y1={yAt(max * t, max)} y2={yAt(max * t, max)} stroke="#d5dde0" />
<text x={PAD.left - 8} y={yAt(max * t, max) + 4} textAnchor="end" fontSize={12} fill="#5b6870">
{max * t}
</text>
</g>
))}
{data.map((d, i) => (
<text key={d.label} x={xAt(i, n)} y={H - 10} textAnchor="middle" fontSize={12} fill="#5b6870">
{d.label}
</text>
))}
<polyline points={points} fill="none" stroke="#00c8e8" strokeWidth={3} strokeLinejoin="round" />
{sel && (
<>
<line className="guide" x1={selX} x2={selX} y1={PAD.top} y2={PAD.top + PLOT_H} stroke="#9aa5ab" strokeDasharray="4 4" />
<circle className="dot" cx={selX} cy={yAt(sel.value, max)} r={6} fill="#fff" stroke="#067a98" strokeWidth={3} />
</>
)}
</svg>
{sel && (
<div
className="tip"
aria-hidden="true"
style={{
left: `${(selX / W) * 100}%`,
top: `${(PAD.top / H) * 100}%`,
// 右半分の点では、線の左側に出す
transform: active! > (n - 1) / 2 ? 'translateX(calc(-100% - 10px))' : 'translateX(10px)',
}}
>
<span className="tip-label">{sel.label}</span>
<strong>
{sel.value}
{unit}
</strong>
</div>
)}
<p className="visually-hidden" aria-live="polite">
{text}
</p>
</div>
);
}
使う側では、横軸の名前と値の組み、縦軸のいちばん上の値を渡します。
import { LineChart } from './LineChart';
import type { Point } from './chart';
// 例のデータ(架空の数字)
const visitors: Point[] = [
{ label: '1月', value: 320 },
{ label: '2月', value: 280 },
{ label: '3月', value: 410 },
{ label: '4月', value: 460 },
{ label: '5月', value: 390 },
{ label: '6月', value: 350 },
{ label: '7月', value: 520 },
{ label: '8月', value: 580 },
{ label: '9月', value: 430 },
{ label: '10月', value: 470 },
{ label: '11月', value: 500 },
{ label: '12月', value: 610 },
];
export function App() {
return <LineChart data={visitors} title="月ごとの来店数" max={800} unit="人" />;
}
選んでいる点の番号を active に持ち、null なら何も出しません。onPointerMove で、ポインターが動くたびに番号を求め直し、マウスがグラフの外に出たら(onPointerLeave)、null に戻します。公式リファレンスの共通の props の説明にあるとおり、onPointerMove などのハンドラーは、ブラウザに組み込みの要素すべてに付けられます。React は、組み込みの SVG の要素もすべて扱えるので、<svg> にもそのまま付けられます。グラフの上にある線や文字は <svg> の子なので、どこに乗せても <svg> のハンドラーが呼ばれます。外に出したあとは、ツールチップも丸も消えました。
ツールチップと縦線の CSS は、次のとおりです。
.line-chart { position: relative; max-width: 640px; }
.line-chart svg { display: block; width: 100%; height: auto; touch-action: pan-y; }
.line-chart:focus-visible { outline: 2px solid #067a98; outline-offset: 4px; }
.tip { position: absolute; pointer-events: none; white-space: nowrap; padding: 6px 10px; border-radius: 6px; background: #1f2a30; color: #fff; font-size: 14px; line-height: 1.4; }
.tip-label { display: block; font-size: 12px; color: #c9d3d8; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; margin: 0; }
縦軸と横軸の文字は、viewBox の単位で 12 の大きさにしています。グラフを 343px に縮めると、文字も同じ割合で小さくなります。画面の幅に合わせると文字の大きさまで変わることと、その対策は、このブログの「Reactで、ライブラリを使わずにSVGで棒グラフを描く方法」で扱っています。
ツールチップは HTML で重ね、位置は割合で指定する
ツールチップは、SVG の <text> ではなく、グラフに重ねた HTML の <div> にしました。HTML なら、文字の大きさが画面の px で決まり、グラフを縮めても小さくなりません。幅 375px の画面では、どの月でもツールチップの幅は 58.8px でした。背景や角の丸みも CSS で指定できます。
置く位置は、viewBox の座標を、グラフの幅と高さに対する割合(%)に直して指定しています。left は「点の x ÷ 640」、top は「上の余白 ÷ 300」です。外側の <div> を position: relative にしておけば、グラフが何 px で表示されていても、割合が同じなので縦線の横に来ます。画面の上でのグラフの大きさを測り直す必要がありません。
右の端の点でツールチップを右側に出すと、グラフからはみ出します。そこで、右半分の点(7月から12月)では、translateX(calc(-100% - 10px)) で、ツールチップを自分の幅の分だけ左へずらし、縦線の左側に出しています。幅 375px の画面(グラフの左端 16、右端 359)で測った位置は、次のとおりでした。
| 月 | 縦線の x | ツールチップの左端〜右端 | 出た側 |
|---|---|---|---|
| 1月 | 41.5 | 51.7〜110.5 | 右側 |
| 6月 | 181.8 | 192.0〜250.8 | 右側 |
| 7月 | 209.8 | 141.3〜200.1 | 左側 |
| 12月 | 350.2 | 281.6〜340.4 | 左側 |
12月でも右側に出すように書き換えると、ツールチップの右端は 419.2 になり、画面の幅(375)を越えました。ツールチップは pointer-events: none にしています。これがないと、ツールチップは <svg> の子ではなく隣に重ねた要素なので、カーソルが乗ったときに <svg> から出たことになります。pointer-events: none を外した版も、幅 800px の画面で試しました。3月を選んだ状態でカーソルをツールチップに乗せると、<svg> に pointerleave が来て、ツールチップが消えました。消えるとカーソルの下は <svg> に戻り、pointerenter が来るので、次に動かすとまた出ます。そのままツールチップの上で右へ 1px ずつ 6 回動かすと、出ては消えるのを3回繰り返しました。pointer-events: none を付けた版では、同じように動かしても「3月 410人」が出たままでした。ツールチップはグラフの上端に置いているので、8月や9月を選んだときのように、ツールチップの下に値の大きい部分(7月〜8月あたり)の線があると、その線が隠れます。
この記事では、どちら側に出すかを点の位置だけで決めています。ツールチップの幅を測って、入りきるかどうかで決める方法は、このブログの「Reactで、ツールチップを画面の端からはみ出さずに出す方法」で扱っています。
タッチでは、指を離しても消さない
スマートフォンでは、指でグラフに触れると pointerdown が来ます。onPointerDown でも同じ処理を呼んでいるので、タップした点の値が出ます。
困るのは、指を離したときです。Playwright でタッチ端末として 3月をタップすると、イベントは pointerdown、pointerup、pointerleave の順に来ました。どれも pointerType は touch です。MDN のポインターイベントの説明にも、指のようにホバーができない入力では、pointerup のあとに pointerout(要素から出たことを知らせるイベント)が来ると書かれています。検証では、同じように pointerleave も来ました。pointerleave でいつも消すように書き換えた版では、タップしても、指を離すとすぐにツールチップが消えました。
そこで、pointerType が mouse のときだけ消すようにしています。この版では、3月をタップしたあとも「3月 410人」が出たままでした。pointerType が pen のときも、ツールチップは消えないコードになっています。
指でグラフを横になぞって、値を順に読めるようにするには、<svg> に touch-action: pan-y を付けます。MDN の touch-action の説明によると、標準では、指でのスクロールやズームはブラウザが受け持ち、ブラウザが受け持ち始めた時点でページには pointercancel が来ます。pan-y は、縦のスクロールだけをブラウザに任せる指定です。
Chrome DevTools Protocol で、1月から12月まで指を22回に分けて動かすタッチ操作を送って比べました。
| touch-action | pointermove | pointercancel | ツールチップに出た月 |
|---|---|---|---|
| pan-y | 22 回 | 0 回 | 1月から12月まで、すべて |
| auto(指定なし) | 2 回 | 1 回 | 1月、2月 |
指定がないと、2回動かしたところでブラウザがスクロールの操作として受け持ち、pointercancel が来て、そのあとの動きはページに届きませんでした。pan-y を付けても、グラフの上で指を縦に 150px なぞると、ページはスクロールしました(scrollY は 174)。touch-action: none にすると、グラフの上では縦のスクロールもできなくなるので、グラフが画面の大部分を占めるページでは使いにくくなります。
キーボードでも値を読めるようにする
マウスもタッチも使わない人のために、グラフ全体を tabIndex={0} の <div> で包み、左右の矢印キーで点を選べるようにしました。Home で最初の点、End で最後の点、Escape で選択を外します。フォーカスが外れたときも(onBlur)、選択を外します。
Tab キーでグラフにフォーカスを移し、続けてキーを押したときのツールチップは、次のように変わりました。
| 押したキー | ツールチップ |
|---|---|
| → | 1月 320人 |
| → | 2月 280人 |
| End | 12月 610人 |
| ← | 11月 500人 |
| Home | 1月 320人 |
| Escape | (消える) |
ツールチップの <div> は aria-hidden="true" にして、読み上げには使っていません。代わりに、画面には見えない <p> に同じ文を入れ、aria-live を polite にしています。MDN によると、aria-live="polite" を付けた要素の中身が変わると、支援技術はふつう、利用者の今の作業を遮らずに、変わったことを伝えます。検証では、矢印キーを押すたびに、この <p> の中身がツールチップと同じ「1月 320人」などに変わりました。
Playwright で取り出した Chromium のアクセシビリティツリーでは、グラフは名前が「月ごとの来店数(左右の矢印キーで値を読む)」のグループ(group)になっていました。線や軸の文字を描いた <svg> は aria-hidden="true" にしているので、ツリーには出てきません。キーで値を読む操作があることを、名前の中で伝えています。





