ReactとSVGで、円や曲線に沿って文字を並べる方法
SVG の <textPath> に、<path> で描いた円や曲線を参照させると、文字がその線に沿って並びます。円は <circle> ではなく、2つの円弧をつないだ <path> で描きます。上半分の文字は時計回り、下半分の文字は反時計回りの円に並べると、どちらも逆さになりません。
円の上下に文字を並べる CircleText、1周に文字を均等に広げる RingText、スライダーで曲がり具合を変えられる CurvedText の3つを作り、文字の位置を Chromium で測りました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用ビルドで動かしました。日本語は Noto Sans CJK JP で表示しています。Firefox と Safari、スマートフォンのブラウザ、本番用ビルド、スクリーンリーダーでの読み上げ、縦書きや右から左に書く言語の文字、<textPath> の method 属性と spacing 属性は試していません。

円は2つの円弧をつないだ path で描く
最初に、<circle> を href で参照させてみました。React を使わずに SVG を直接書いて試したところ、Chromium 153 では文字の位置がすべて (0, 0) のままで、円に沿って並びませんでした。同じ円を <path> で描いて参照させると、円に沿って並びました。
SVG 2 の仕様にある href 属性の説明では、<circle> のような基本図形も参照先にできると書かれています。それでも、今回の Chromium では <path> が必要でした。
円の d 属性は、次の関数で作ります。左端(9時の位置)から始めて、反対側の右端までの円弧と、右端から左端に戻る円弧をつなぎます。
// paths.ts
// 円を、左端(9時の位置)から始まる2つの円弧で描く d 属性を作る
// clockwise: 時計回り(上半分を先に通る) / counterclockwise: 反時計回り(下半分を先に通る)
export function circlePath(
cx: number,
cy: number,
r: number,
direction: 'clockwise' | 'counterclockwise',
): string {
const sweep = direction === 'clockwise' ? 1 : 0;
return [
`M ${cx - r} ${cy}`,
`A ${r} ${r} 0 1 ${sweep} ${cx + r} ${cy}`,
`A ${r} ${r} 0 1 ${sweep} ${cx - r} ${cy}`,
].join(' ');
}
// 左右の端(高さ y)を結ぶ2次ベジェ曲線。真ん中が bend だけ上に(負なら下に)ずれる
export function curvePath(width: number, y: number, bend: number): string {
return `M 0 ${y} Q ${width / 2} ${y - bend * 2} ${width} ${y}`;
}
A の5つ目の値(sweep)が 1 なら時計回り、0 なら反時計回りに進みます。時計回りの円では、左端から上を通って右端へ進むので、文字は上半分から並び始めます。反時計回りの円では、左端から下を通って右端へ進みます。curvePath は、後の節の曲線で使います。
上の文字は時計回り、下の文字は反時計回りの円に並べる
<textPath> の文字は、線の進む向きに並びます。MDN の side 属性の説明によると、初期値の left では、文字は進む向きの左側に置かれます。時計回りの円の下半分は、右から左へ進むので、そこに並べた文字は逆さになります。そこで、下の文字は反時計回りの円に並べます。
// CircleText.tsx
import { useId } from 'react';
import { circlePath } from './paths';
type Props = {
top: string; // 上半分に、時計回りに並べる文字
bottom?: string; // 下半分に、左から右へ並べる文字
size?: number; // SVG の幅と高さ
radius?: number; // 文字を並べる円の半径
fontSize?: number;
};
export function CircleText({ top, bottom, size = 240, radius = 90, fontSize = 20 }: Props) {
const id = useId();
const c = size / 2;
return (
<svg viewBox={`0 0 ${size} ${size}`} width={size} height={size} role="img" aria-label={[top, bottom].filter(Boolean).join(' ')}>
<defs>
<path id={`${id}-cw`} d={circlePath(c, c, radius, 'clockwise')} />
<path id={`${id}-ccw`} d={circlePath(c, c, radius, 'counterclockwise')} />
</defs>
<circle cx={c} cy={c} r={radius} fill="none" stroke="#00c8e8" strokeDasharray="4 4" />
<text fontSize={fontSize} dominantBaseline="central" fill="#1f2a30">
<textPath href={`#${id}-cw`} startOffset="25%" textAnchor="middle">
{top}
</textPath>
</text>
{bottom && (
<text fontSize={fontSize} dominantBaseline="central" fill="#1f2a30">
<textPath href={`#${id}-ccw`} startOffset="25%" textAnchor="middle">
{bottom}
</textPath>
</text>
)}
</svg>
);
}
どちらの円も左端から始まるので、1周の 25% の位置は、時計回りなら真上、反時計回りなら真下になります。startOffset をパーセントで書くと、線の全体の長さに対する割合になります。text-anchor を middle にすると、文字列の真ん中がその位置に来ます。
半径 90 の円で、文字の中心の角度を測りました(右を 0°、下を +90° としています)。上の「それなら猫の手で」は、最初の文字が −134.6°、最後の文字が −45.4° で、真上(−90°)をはさんで左右に並びました。下の「NEKO NOTE BLOG」は、最初の文字が 138.7°、最後の文字が 41.1° でした。文字の横の位置は左から右へ増えていき、左から右へ読める向きで並びました。
参照する <path> の id は、useId で作ります。id を決め打ちにすると、同じコンポーネントを2つ置いたときに、同じ id がページに2つできてしまいます。円を2つ並べたところ、href は #_r_1_-cw と #_r_2_-cw のように分かれ、2つめの円の文字も、その円に沿って並びました。React 19.3.1 の useId は _r_0_ のような値を返し、# を付けて href にそのまま使えました。
属性の名前は、React のリファレンスにある SVG コンポーネントの一覧の注記にあるとおり、camelCase で書きます。dominant-baseline は dominantBaseline、text-anchor は textAnchor です。参照先は href で指定します。MDN の href の説明では、SVG 2 より前の仕様にあった xlink:href は、href に置き換えられて廃止されたものとされています(React で書くなら xlinkHref)。
dominantBaseline="central" で、文字の中心を円に乗せる
dominantBaseline を指定しないと、文字のベースライン(欧文の文字が乗る線)が円に乗ります。上の文字は円の外側に、下の文字は円の内側に出るので、上下で文字の位置がそろいません。

半径 90 の円で、文字の中心から円の中心までの距離を測りました。dominantBaseline を外すと、上の文字は 98.5、下の文字は 81.5 でした。dominantBaseline="central" を付けると、上も下も 90.0 になり、文字の中心が円に乗りました。
円の1周に、文字を均等に広げる
textLength に長さを指定し、lengthAdjust を spacing にすると、文字の間隔が広がって、文字列がその長さになります。ただ、textLength に円周そのものを指定すると、最後の文字が最初の文字にくっつきます。
半径 90(円周 565.5)の円に「それなら猫の手で・NEKO NOTE・」の19文字を並べ、textLength を円周そのものにしたところ、1文字目と2文字目の間は 16.1 空きました。最後の文字と最初の文字の間は −1.1 で、少し重なりました。広げた分(565.5 − 279.5 = 286.0)を、文字と文字の間の18か所で割ると 15.9 で、1文字目と2文字目の間とほぼ同じです。最後の文字の後ろには、間隔が付きませんでした。
そこで、間隔を1つぶん引いた長さを指定します。広げる前の文字列の長さは、見えない <text> で測ります。
// RingText.tsx
import { useId, useLayoutEffect, useRef, useState } from 'react';
import { circlePath } from './paths';
type Props = {
text: string;
size?: number;
radius?: number;
fontSize?: number;
};
// 文字を円の1周に均等に広げる
export function RingText({ text, size = 240, radius = 90, fontSize = 18 }: Props) {
const id = useId();
const measureRef = useRef<SVGTextElement>(null);
const [natural, setNatural] = useState(0); // 間隔を広げる前の、文字列の長さ
const c = size / 2;
const circumference = 2 * Math.PI * radius;
const count = Array.from(text).length;
// 見えない text で、広げる前の長さを測る
useLayoutEffect(() => {
setNatural(measureRef.current!.getComputedTextLength());
}, [text, fontSize]);
// 文字と文字の間(count か所。最後の文字と最初の文字の間も含む)を同じにする
const gap = (circumference - natural) / count;
return (
<svg viewBox={`0 0 ${size} ${size}`} width={size} height={size} role="img" aria-label={text}>
<defs>
<path id={id} d={circlePath(c, c, radius, 'clockwise')} />
</defs>
<text ref={measureRef} fontSize={fontSize} visibility="hidden">
{text}
</text>
{natural > 0 && (
<text fontSize={fontSize} dominantBaseline="central" fill="#1f2a30">
<textPath href={`#${id}`} textLength={circumference - gap} lengthAdjust="spacing">
{text}
</textPath>
</text>
)}
</svg>
);
}
19文字の長さは、広げる前が 279.5 でした。間隔は (565.5 − 279.5) ÷ 19 = 15.1 で、textLength は 565.5 − 15.1 = 550.4 になります。測ると、文字と次の文字の間は 14.9〜15.3、最後の文字と最初の文字の間は 14.0 でした。最後の間だけ約1狭くなりました。円周そのものを指定したときに最後の間が −1.1 だったのと、ほぼ同じ差です。見た目では、ほぼ均等に並びました。
1周に並べると、円の下半分の文字は逆さになります(画像の右の円の「NOTE」)。下半分も読める向きにしたいときは、前の節の CircleText のように、上と下で文字列を分けます。
曲線に沿わせ、スライダーで曲がり具合を変える
曲線には、2次ベジェ曲線(Q)を使いました。curvePath は、左右の端を同じ高さに置き、制御点を真ん中の上に置きます。制御点を bend の2倍だけ上げると、曲線の真ん中が bend だけ上がります。両端の y が 100 のとき、曲がり具合 40 では曲線の真ん中の点の y が 60.0、−40 では 140.0 になりました。
// CurvedText.tsx
import { useId, useLayoutEffect, useRef, useState } from 'react';
import { curvePath } from './paths';
type Props = {
text: string;
width?: number;
fontSize?: number;
initialBend?: number;
};
export function CurvedText({ text, width = 320, fontSize = 20, initialBend = 40 }: Props) {
const id = useId();
const [bend, setBend] = useState(initialBend);
const pathRef = useRef<SVGPathElement>(null);
const textRef = useRef<SVGTextPathElement>(null);
const [overflow, setOverflow] = useState(false);
const height = 170;
const d = curvePath(width, 100, bend);
// 文字列の長さが曲線の長さを超えたら、警告を出す
useLayoutEffect(() => {
const pathLength = pathRef.current!.getTotalLength();
const textLength = textRef.current!.getComputedTextLength();
setOverflow(textLength > pathLength);
}, [d, text, fontSize]);
return (
<div>
<svg viewBox={`0 0 ${width} ${height}`} width={width} height={height} role="img" aria-label={text}>
<path ref={pathRef} id={id} d={d} fill="none" stroke="#00c8e8" strokeDasharray="4 4" />
<text fontSize={fontSize} fill="#1f2a30">
<textPath ref={textRef} href={`#${id}`} startOffset="50%" textAnchor="middle">
{text}
</textPath>
</text>
</svg>
<label>
曲がり具合:{bend}
<input type="range" min={-60} max={60} value={bend} onChange={(e) => setBend(Number(e.target.value))} />
</label>
{overflow && <p role="alert">文字が曲線に収まりきっていません</p>}
</div>
);
}
d は bend から毎回計算するので、スライダーを動かすと曲線と文字が一緒に動きます。startOffset="50%" と textAnchor="middle" で、文字列の真ん中が曲線の真ん中に来ます。

幅 320 の曲線で、スライダーの値を変えて測った結果です。
| 曲がり具合 | 曲線の長さ | 真ん中の文字の中心の y 座標(下ほど大きい) |
|---|---|---|
| 60 | 347.9 | 31.7 |
| 40 | 332.9 | 51.7 |
| 0 | 320.0 | 91.5 |
| −40 | 332.9 | 131.3 |
| −60 | 347.9 | 151.3 |
値を負にすると曲線は下にふくらみますが、文字は曲線の上側に並んだままで、逆さにはなりません。曲線は左から右へ進むので、進む向きの左側は画面の上側です。
曲線より長い文字列は、両端の文字が消える
SVG 2 の仕様では、真ん中の位置が線の端からはみ出した文字は、描かれないことになっています。textAnchor="middle" で真ん中にそろえていると、はみ出すのは両端です。
20文字の「それなら猫の手で、曲線に沿って文字を並べ」(長さ 398.2)を、長さ 320.0 のまっすぐな線に並べたところ、描かれたのは16文字でした。頭の「それ」と終わりの「並べ」が消えました。曲がり具合を 60(曲線の長さ 347.9)にすると、消えるのは「そ」と「べ」の2文字に減りましたが、まだ収まりませんでした。

Chromium では、文字が消えてもコンソールにエラーや警告は出ませんでした。そこで CurvedText では、getComputedTextLength() で測った文字列の長さが、getTotalLength() で測った曲線の長さを超えたら、警告を出しています。曲線の d が変わるたびに測り直すので、曲がり具合によって警告が出たり消えたりします。17文字の「それなら猫の手で、曲線に沿って並ぶ」(長さ 338.2)では、曲がり具合が 0、30、45 のときは警告が出て、60(曲線の長さ 347.9)にすると消えました。
ただし、この警告は文字が消えるより早めに出ます。前に書いたとおり、仕様で描かれなくなるのは、文字の真ん中の位置が線の端からはみ出したときだけだからです。17文字の例では、警告が出ていた曲がり具合 0、30、45 のときも、17文字すべてが描かれていました。CurvedText の判定は、文字列の全体が曲線に収まっているかどうかを見ています。端の文字が少しでも曲線の外にはみ出したら、文字が消える前でも警告を出します。
path 属性と side 属性を Chromium で試した結果
MDN の textPath の説明には、試験的な機能として、ほかにも2つの属性が載っています。参照先を使わずに、線の形を直接書ける path と、線のどちら側に文字を置くかを決める side です。
React を使わずに SVG を直接書いて試したところ、Chromium 153 では、path 属性に円弧を書くと、<path> を参照させたときと同じ位置に文字が並びました。side="right" は、付けても付けなくても文字の位置が同じで、円の内側には移りませんでした。@types/react 19.3.0 の SVG の props には path はありましたが、side はありませんでした。





