ReactとSVGで、円や曲線に沿って文字を並べる方法

​​React​Playwright,SVG,TypeScript

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 属性は試していません。

左は、点線の円の上側に「それなら猫の手で」、下側に「NEKO NOTE BLOG」が、どちらも左から右へ読める向きで並んだ円。右は、「それなら猫の手で・NEKO NOTE・」が円の1周に同じ間隔で広がった円で、下半分の「NOTE」は逆さになっている
CircleText(左)と RingText(右)。どちらも文字の中心が円に乗っている

円は2つの円弧をつないだ path で描く

最初に、<circle> を href で参照させてみました。React を使わずに SVG を直接書いて試したところ、Chromium 153 では文字の位置がすべて (0, 0) のままで、円に沿って並びませんでした。同じ円を <path> で描いて参照させると、円に沿って並びました。

SVG 2 の仕様にある href 属性の説明では、<circle> のような基本図形も参照先にできると書かれています。それでも、今回の Chromium では <path> が必要でした。

円の d 属性は、次の関数で作ります。左端(9時の位置)から始めて、反対側の右端までの円弧と、右端から左端に戻る円弧をつなぎます。

ts
// 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 では、文字は進む向きの左側に置かれます。時計回りの円の下半分は、右から左へ進むので、そこに並べた文字は逆さになります。そこで、下の文字は反時計回りの円に並べます。

tsx
// 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 を指定しないと、文字のベースライン(欧文の文字が乗る線)が円に乗ります。上の文字は円の外側に、下の文字は円の内側に出るので、上下で文字の位置がそろいません。

点線の円の上側の「それなら猫の手で」は円の外側に、下側の「NEKO NOTE BLOG」は円の内側に並んでいる
dominantBaseline を外したときの CircleText。上の文字は外側に、下の文字は内側にずれる

半径 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> で測ります。

tsx
// 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 になりました。

tsx
// 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" で、文字列の真ん中が曲線の真ん中に来ます。

上は、上にふくらんだ点線の曲線の上に「それなら猫の手で」が弧を描いて並び、下に「曲がり具合:40」のスライダーがある。下は、下にふくらんだ曲線の上に同じ文字が並び、「曲がり具合:-40」のスライダーがある
曲がり具合を 40 と −40 にした CurvedText

幅 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文字に減りましたが、まだ収まりませんでした。

点線の上に「なら猫の手で、曲線に沿って文字を」だけが並び、その下に「曲がり具合:0」のスライダーと、「文字が曲線に収まりきっていません」という警告がある
曲線より長い文字列。両端の文字が描かれず、警告が出ている

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 はありませんでした。

​Posted by 86