Reactで、2つの箱を線でつなぐ図をSVGで描く方法
2つの箱を線でつなぐには、箱は HTML の要素として置き、同じ大きさの SVG を上に重ねて、箱の位置を測った座標で <path> を描きます。座標は SVG 自身の左上を原点にして測ると、ページのスクロールや図の枠線に左右されません。測り直すのは、親が再レンダーされたとき(useLayoutEffect)と、箱の大きさだけが変わったとき(ResizeObserver)の2つの場合です。
検証環境は 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 を操作して、線の座標や画面の色を確かめました。Firefox と Safari、スマートフォンの実機、スクリーンリーダーでの読み上げは試していません。3つ以上の箱をつなぐ図や、線が箱をよけて通る道筋の計算は扱いません。

箱は HTML で置き、線は上に重ねた SVG で描く
箱の中には文字やボタンが入るので、折り返しや余白を CSS に任せられる HTML の要素で作ります。線だけを SVG にし、図の全体を覆う大きさで箱の上に重ねます。
.diagram {
position: relative; /* 箱と SVG の位置の基準 */
height: 280px;
margin-bottom: 16px;
border: 1px solid #9aa5ab;
background: #eefafd;
}
.box {
position: absolute;
width: 140px;
padding: 8px 12px;
border: 2px solid #067a98;
border-radius: 8px;
background: #fff;
}
.draggable { cursor: grab; touch-action: none; user-select: none; }
.connector {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none; /* 線の下にある箱やボタンを押せるようにする */
}
JSX では SVG を箱より後に書いているので、画面では SVG が箱の上に重なります。そのため pointer-events: none が欠かせません。検証では、これを auto に戻すと、箱の中のボタンの位置にある要素が svg.connector になり、クリックしてもボタンの文字が「詳しく」のまま変わりませんでした。none のときは同じ位置の要素が button になり、クリックで「閉じる」に変わりました。
overflow: visible は、箱が図の外へはみ出したときのためです。MDN の overflow 属性の説明にあるとおり、HTML の中に置いた <svg> は、ブラウザの既定のスタイルで外側を切り取られます。箱を図の下の枠より外へ動かして、枠の 8px 下の色を調べたところ、visible では線の縁の水色(#5ddcf0。線の色 #00c8e8 が背景と混ざった色)、hidden では背景の白(#ffffff)でした。
線を引く Connector コンポーネント
線を引く部分を、つなぐ2つの箱の ref を受け取るコンポーネントにしました。
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import type { RefObject } from 'react';
type Point = { x: number; y: number };
// SVG の左上を原点にした、el の位置と中心
function rectIn(el: HTMLElement, svg: SVGSVGElement) {
const r = el.getBoundingClientRect();
const o = svg.getBoundingClientRect();
const left = r.left - o.left, right = r.right - o.left, top = r.top - o.top, bottom = r.bottom - o.top;
return { left, right, top, bottom, cx: (left + right) / 2, cy: (top + bottom) / 2 };
}
// 2つの箱の、向かい合う辺の真ん中どうしを結ぶ曲線
function connectorPath(svg: SVGSVGElement, a: HTMLElement, b: HTMLElement) {
const ra = rectIn(a, svg);
const rb = rectIn(b, svg);
const dx = rb.cx - ra.cx;
const dy = rb.cy - ra.cy;
let from: Point, to: Point, bend: Point;
if (Math.abs(dx) >= Math.abs(dy)) {
// 左右に離れているときは、左右の辺どうしをつなぐ
const s = dx >= 0 ? 1 : -1;
from = { x: s > 0 ? ra.right : ra.left, y: ra.cy };
to = { x: s > 0 ? rb.left : rb.right, y: rb.cy };
bend = { x: s * Math.max(40, Math.abs(to.x - from.x) / 2), y: 0 };
} else {
// 上下に離れているときは、上下の辺どうしをつなぐ
const s = dy >= 0 ? 1 : -1;
from = { x: ra.cx, y: s > 0 ? ra.bottom : ra.top };
to = { x: rb.cx, y: s > 0 ? rb.top : rb.bottom };
bend = { x: 0, y: s * Math.max(40, Math.abs(to.y - from.y) / 2) };
}
// 制御点を辺と垂直の向きにずらすと、辺からまっすぐ出て、まっすぐ入る線になる
return `M ${from.x} ${from.y} C ${from.x + bend.x} ${from.y + bend.y} ${to.x - bend.x} ${to.y - bend.y} ${to.x} ${to.y}`;
}
type Props = {
fromRef: RefObject<HTMLElement | null>;
toRef: RefObject<HTMLElement | null>;
color?: string;
};
export function Connector({ fromRef, toRef, color = '#00c8e8' }: Props) {
const svgRef = useRef<SVGSVGElement>(null);
const [d, setD] = useState('');
const markerId = useId();
function update() {
const svg = svgRef.current, a = fromRef.current, b = toRef.current;
if (svg && a && b) setD(connectorPath(svg, a, b)); // 同じ文字列なら再レンダーされない
}
// 親が再レンダーされるたびに(箱を動かしたときなど)、画面に出る前に測り直す
useLayoutEffect(update);
// 親が再レンダーされないまま、箱や図の大きさが変わったときも測り直す
useEffect(() => {
const ro = new ResizeObserver(update);
for (const el of [svgRef.current, fromRef.current, toRef.current]) {
if (el) ro.observe(el);
}
return () => ro.disconnect();
}, []);
return (
<svg ref={svgRef} className="connector" aria-hidden="true">
<defs>
<marker
id={markerId}
viewBox="0 0 10 10"
refX="10"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill={color} />
</marker>
</defs>
{d && <path d={d} fill="none" stroke={color} strokeWidth="2" markerEnd={`url(#${markerId})`} />}
</svg>
);
}
使う側では、2つの箱に ref を付け、同じ親の中に Connector を置きます。検証に使った図では、箱 B をドラッグで動かせるようにし、箱 A には押すと説明が開いて縦に伸びる「詳しく」ボタンを入れました。
import { useRef, useState } from 'react';
import type { PointerEvent } from 'react';
import { Connector } from './Connector';
// 中の「詳しく」で高さが変わる箱。開閉の state は箱の中だけにある
function DetailBox({ title, detail }: { title: string; detail: string }) {
const [open, setOpen] = useState(false);
return (
<>
<strong>{title}</strong>
{open && <p>{detail}</p>}
<button type="button" onClick={() => setOpen((o) => !o)}>
{open ? '閉じる' : '詳しく'}
</button>
</>
);
}
type Pos = { x: number; y: number };
export function Diagram({ color }: { color?: string }) {
const aRef = useRef<HTMLDivElement>(null);
const bRef = useRef<HTMLDivElement>(null);
const [posB, setPosB] = useState<Pos>({ x: 220, y: 150 });
const drag = useRef<{ dx: number; dy: number } | null>(null);
// 箱 B をドラッグで動かす(位置は state に持つ)
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { dx: e.clientX - posB.x, dy: e.clientY - posB.y };
}
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
if (!drag.current) return;
setPosB({ x: e.clientX - drag.current.dx, y: e.clientY - drag.current.dy });
}
function handlePointerUp() {
drag.current = null;
}
return (
<div className="diagram">
<div className="box" ref={aRef} style={{ left: 16, top: 24 }}>
<DetailBox title="注文を受ける" detail="在庫を確かめてから、受け付けます。" />
</div>
<div
className="box draggable"
ref={bRef}
style={{ left: posB.x, top: posB.y }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
<strong>発送する</strong>
</div>
<Connector fromRef={aRef} toRef={bRef} color={color} />
</div>
);
}
座標は SVG 自身の左上を原点にして測る
getBoundingClientRect() が返す位置は、表示領域の左上を原点にした値です。そのままでは SVG の中の座標にならないので、箱の値から SVG 自身の getBoundingClientRect() を引きます。どちらもスクロールで同じだけずれるため、差はスクロールに左右されません。検証では、2つ目の図の「詳しく」を押して箱 A を伸ばした後の線を、スクロールしないときと、ページを 200px スクロールしたときで比べました。どちらも (184,82) → (220,172) で、同じでした。
SVG は図の枠線の内側いっぱいに広がっているので、SVG の左上は、箱の left・top の基準と同じ点です。最初の表示では、箱 A の右の辺が x = 184、上下の真ん中が y = 60 で、線の始点も (184,60) になりました。図には 1px の枠線がありますが、その幅を足したり引いたりする計算はいりませんでした。また、SVG に viewBox を付けていないこの書き方で、path の数値どおりの位置に線と矢印が描かれることを、その位置の画面の色で確かめました。
原点を SVG の ref で取るのには、もう1つ理由があります。比べるために、図の外側の div に ref を付けて Connector に渡し、その div を原点にする版も作りました。どちらも ResizeObserver を外して最初の表示を調べると、div を原点にする版では線が描かれませんでした。Connector の useLayoutEffect が最初に動いた時点で、箱の ref には要素が入っていたのに、外側の div の ref は空だったためです。
原点を SVG の ref で取る:
console.log: useLayoutEffect: svgRef true fromRef true toRef true
console.log: useLayoutEffect: svgRef true fromRef true toRef true
console.log: useLayoutEffect: svgRef true fromRef true toRef true
console.log: useLayoutEffect: svgRef true fromRef true toRef true
1つ目の図: 箱A 16,24〜184,96 / 箱B 220,150〜388,194 / 線 (184,60) → (220,172)
原点を親の div の ref で取る:
console.log: useLayoutEffect: containerRef false fromRef true toRef true
console.log: useLayoutEffect: containerRef false fromRef true toRef true
1つ目の図: 線なし
ページには図を2つ置いているので、ログは図ごとに出ています。
つまり、Connector の中の SVG と、Connector より前に並ぶ箱の ref には、最初の useLayoutEffect の時点で要素が入っていましたが、Connector を囲む親の div の ref には入っていませんでした。div を原点にする版に ResizeObserver を付けたときの動きは試していません。
向かい合う辺の真ん中どうしを曲線で結ぶ
線の両端は、箱の中心どうしの横の差と縦の差を比べて決めます。横に離れているときは左右の辺の真ん中を、縦に離れているときは上下の辺の真ん中を結びます。箱 B を箱 A の下へ動かすと、線は (100,96) → (104,220) になり、箱 A の下の辺から箱 B の上の辺へつながりました。
曲線には、path の C コマンド(3次ベジェ曲線)を使います。2つの制御点を、始点と終点から辺と垂直な向きにずらすと、線は辺からまっすぐ出て、まっすぐ入ります。ずらす量を 40 以上にしているのは、箱どうしが近いときでも曲がり方が分かるようにするためです。
測り直すタイミングは2つある
箱 B のドラッグでは、親の Diagram の state(posB)が変わって再レンダーされます。依存配列を書かない useLayoutEffect(update) は、React の useLayoutEffect のリファレンスにあるとおり、コミットのたびに、ブラウザが画面を描き直す前に動きます。依存配列を [] にすると、最初の1回しか測りません。
useLayoutEffect(update): 右下へ: 箱A 16,24〜184,96 / 箱B 300,190〜468,234 / 線 (184,60) → (300,212)
useLayoutEffect(update): 箱Aの下へ: 箱A 16,24〜184,96 / 箱B 20,220〜188,264 / 線 (100,96) → (104,220)
useLayoutEffect(update, []): 右下へ: 箱A 16,24〜184,96 / 箱B 300,190〜468,234 / 線 (184,60) → (220,172)
useLayoutEffect(update, []): 箱Aの下へ: 箱A 16,24〜184,96 / 箱B 20,220〜188,264 / 線 (184,60) → (220,172)
[] のほうは、箱 B が動いても線の終点が (220,172) に残りました。箱の位置が変わっても大きさは変わらないので、ResizeObserver も知らせてくれません。
測るたびに setD を呼んでも、繰り返し再レンダーされることはありません。d を文字列で持っているので、座標が変わらなければ前と同じ値になり、useState のリファレンスにあるとおり、React は再レンダーを省きます。上のログでも、SVG の ref で取る版の useLayoutEffect は、最初の表示で2つの図を合わせて4回動いただけで、動き続けることはありませんでした。
一方、箱 A の「詳しく」は、開閉の state が箱の中の DetailBox にしかありません。押しても Diagram と Connector は再レンダーされず、useLayoutEffect は動きません。箱の大きさの変化を知らせる ResizeObserver で測り直します。
ResizeObserver あり: 箱A 16,24〜184,140 / 箱B 220,150〜388,194 / 線 (184,82) → (220,172)
ResizeObserver なし: 箱A 16,24〜184,140 / 箱B 220,150〜388,194 / 線 (184,60) → (220,172)
箱 A の下の辺が 96 から 140 へ伸び、上下の真ん中は 82 になりました。ResizeObserver がない版では、線の始点が箱 A の真ん中より上の 60 に残りました。コードでは SVG 自身も監視していますが、図の大きさを変えたときの動きは試していません。
矢印は marker で付け、id は useId で作る
矢印の形は <marker> に描き、線の marker-end から url(#id) で指します。orient 属性の説明(英語)にあるとおり、auto-start-reverse を指定すると、矢印が線の向きに合わせて回ります。marker-end で使う分には auto と同じで、同じ矢印を marker-start にも付けたときに、始点では向きが逆になって外を向きます(MDN の説明によるもので、この記事では試していません)。marker 要素の refX="10" と refY="5" は、三角の先の点 (10,5) を線の終点に合わせる指定です。
この id を 'arrow' のような決まった文字列にすると、同じ図を2つ置いたときに id が重なります。検証では、2つ目の図の線をピンクにしたところ、線はピンク(#d85878)なのに、矢印は1つ目の図の水色(#00c8e8)で描かれました。useId で作ると、id は _r_0_ と _r_1_ に分かれ、2つ目の図の矢印もピンクになりました。
この図では、線を見た目のための飾りとして扱い、SVG に aria-hidden="true" を付けて、アクセシビリティツリーから外しています。そのため、箱どうしのつながりを読み手に伝えたいときは、線とは別に、文章や一覧でも書いておく必要があります。





