Reactで、ライブラリを使わずにSVGで棒グラフを描く方法

​​React​CSS,Playwright,SVG,TypeScript

棒グラフは、SVG の <rect> を並べるだけで描けます。グラフのライブラリを入れなくても、値を座標に変える計算と、目盛りの値を決める短い関数があれば足ります。目盛りは 1・2・5 の間隔で区切り、置き場所の幅を測って viewBox に渡せば、スマートフォンの幅でも文字が縮みません。データを切り替えたときの伸び縮みは、CSS の transition だけで付けられます。

補足

検証環境は 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、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。負の値を含むデータも扱っていません。

値を座標に変える計算

MDN の SVG の座標の説明にあるとおり、SVG の座標は左上が原点で、y は下に向かって増えます。棒グラフは下から上に伸びるので、値が大きいほど y を小さくします。

このコンポーネントでは、SVG の全体を幅 480・高さ 280 とし、周りに目盛りの文字を置く余白を取りました。棒を描く範囲は、高さが 280 − 24 − 32 = 224 です。目盛りの最大が 250 のとき、値 120 の棒は次のようになります。

  • 棒の高さ:120 ÷ 250 × 224 = 107.52
  • 棒の上端の y:224 − 107.52 = 116.48

実際に描いた <rect> の属性も、y="116.48"、height="107.52" でした。横方向は、描く範囲の幅(416)を項目の数で等分し、その6割を棒の幅にしています。6項目なら、1項目に割り当てる幅が約 69.3、棒の幅が 41.6 です。

目盛りは 1・2・5 の間隔で区切る

データの最大値をそのまま目盛りの最大にすると、310 や 87 のような半端な数で終わり、読みにくくなります。そこで、最大値を5つくらいに分けたときの間隔を、1・2・5 × 10 のべき乗のうち、それ以上でいちばん近い値に切り上げます。

ts
// 0 から max までを、1・2・5 × 10 のべき乗の間隔で区切った目盛りの値を返す
export function niceTicks(max: number, count = 5): number[] {
  if (!(max > 0)) return [0, 1];
  const rough = max / count; // この間隔で count 本に分けると、ちょうど max に届く
  const pow = 10 ** Math.floor(Math.log10(rough));
  const step = [1, 2, 5, 10].map((m) => m * pow).find((s) => s >= rough)!;
  const n = Math.ceil(max / step);
  // 0.1 × 3 が 0.30000000000000004 にならないよう、桁を丸める
  return Array.from({ length: n + 1 }, (_, i) => Number((i * step).toPrecision(12)));
}

いくつかの最大値で呼ぶと、次の目盛りになりました。

データの最大値 目盛り
210 0, 50, 100, 150, 200, 250
310 0, 100, 200, 300, 400
7 0, 2, 4, 6, 8
0.87 0, 0.2, 0.4, 0.6, 0.8, 1
1234 0, 500, 1000, 1500
0 0, 1

最後の toPrecision(12) は、小数の誤差を消すためのものです。検証環境の Node.js で 0.1 * 3 を計算すると 0.30000000000000004 になりました。丸めないと、この値が目盛りの文字にそのまま出ます。値がすべて 0 のときは、0 で割らないように、目盛りを 0 と 1 にしています。

BarChart のコード

棒、値の文字、項目名、目盛りの線と文字を、1つの <svg> に描きます。

tsx
import { useId } from 'react';
import { niceTicks } from './niceTicks';

export type Datum = { label: string; value: number };

type Props = {
  data: Datum[];
  title: string; // 読み上げ用の名前(<title> に入る)
  width?: number; // viewBox の幅と高さ(座標の単位)
  height?: number;
};

// 目盛りの文字や項目名を置くための余白
const margin = { top: 24, right: 16, bottom: 32, left: 48 };

export function BarChart({ data, title, width = 480, height = 280 }: Props) {
  const titleId = useId();
  const descId = useId();
  const innerW = width - margin.left - margin.right;
  const innerH = height - margin.top - margin.bottom;

  const ticks = niceTicks(Math.max(...data.map((d) => d.value)));
  const yMax = ticks[ticks.length - 1];
  // 値を、描く範囲の上端からの位置に変える(SVG の y は下向きに増える)
  const y = (v: number) => innerH - (v / yMax) * innerH;

  const band = innerW / data.length; // 1本あたりの幅
  const barW = band * 0.6; // 棒の幅(残りはすき間)

  return (
    <svg
      viewBox={`0 0 ${width} ${height}`}
      role="img"
      aria-labelledby={titleId}
      aria-describedby={descId}
      style={{ display: 'block', width: '100%', height: 'auto' }}
    >
      <title id={titleId}>{title}</title>
      {/* 読み上げ用に、すべての値を文章にしておく */}
      <desc id={descId}>{data.map((d) => `${d.label} ${d.value.toLocaleString()}`).join('、')}</desc>
      <g transform={`translate(${margin.left},${margin.top})`}>
        {ticks.map((t) => (
          <g key={t} className="tick" transform={`translate(0,${y(t)})`}>
            <line x2={innerW} stroke="#e3e8ea" />
            <text x={-8} dy="0.32em" textAnchor="end" fontSize={12} fill="#5b6870">
              {t.toLocaleString()}
            </text>
          </g>
        ))}
        {data.map((d, i) => {
          const x = i * band + (band - barW) / 2;
          return (
            <g key={d.label}>
              <rect
                className="bar"
                x={x}
                width={barW}
                y={y(d.value)}
                height={innerH - y(d.value)}
                fill="#00c8e8"
              />
              <text
                className="value"
                x={x + barW / 2}
                textAnchor="middle"
                fontSize={12}
                fill="#1f2a30"
                // text の y は CSS で動かせないので、transform で位置を決める(CSS の transition が効く)
                style={{ transform: `translateY(${y(d.value) - 6}px)` }}
              >
                {d.value.toLocaleString()}
              </text>
              <text x={x + barW / 2} y={innerH + 20} textAnchor="middle" fontSize={12} fill="#1f2a30">
                {d.label}
              </text>
            </g>
          );
        })}
        <line y1={innerH} x2={innerW} y2={innerH} stroke="#5b6870" />
      </g>
    </svg>
  );
}

全体を <g transform="translate(48,24)"> で余白の分だけずらしているので、中では描く範囲の左上を原点として計算できます。目盛りも、1本ずつ <g> を目盛りの高さに移し、その中に線と文字を置いています。

文字の位置は、MDN の text-anchor の説明にあるとおり、textAnchor で x の位置にそろえる側を決めます。目盛りの数字は end(右端を x にそろえる)、値と項目名は middle(棒の真ん中にそろえる)です。目盛りの数字は dy="0.32em" で少し下げ、線の高さと文字の真ん中が合うようにしています(下のスクリーンショットで、数字が線の高さにそろっているのが分かります)。

<title> と <desc> は、グラフとしては描かれない、読み上げ用の名前と説明です。<title> は、マウスを重ねるとツールチップで出すブラウザが多いとされています(MDN の SVG の title の説明、desc の説明)。グラフの形は読み上げられないので、<desc> にはすべての値を文章にして入れました。id は useId で作っているので、同じページにグラフを2つ置いても重なりません。Chromium のアクセシビリティツリーでは、このグラフは名前が「2026年の月ごとの件数」、説明が「1月 150、2月 130、3月 260、4月 190、5月 240、6月 310」の画像(image)になっていました。

画面の幅に合わせると、文字の大きさまで変わる

viewBox を付けた SVG に width: 100% を指定すると、置き場所の幅いっぱいに拡大・縮小されます(MDN の viewBox の説明)。手軽ですが、棒と一緒に文字も同じ割合で縮みます。

viewBox の幅を 480 に固定したまま、画面の幅を変えて、目盛りの文字(fontSize は 12)が画面の上で何 px になるかを測りました。

画面の幅 viewBox を 480 に固定 置き場所の幅を測る
800px 19.2px(グラフの高さ 448px) 12.0px(グラフの高さ 280px)
360px 8.2px(グラフの高さ 191px) 12.0px(グラフの高さ 280px)
画面の幅 360px で、上に viewBox の幅を 480 に固定したグラフ、下に置き場所の幅を測ったグラフを縦に並べた画面。上は目盛りや月の文字が小さく、グラフ全体も低い。下は文字が大きく読みやすい
画面の幅 360px で比べた様子。上が viewBox の幅を固定した場合、下が置き場所の幅を測った場合

スマートフォンの幅では、文字が 8px ほどになり、読みにくくなりました。広い画面では、逆に文字もグラフも大きくなりすぎます。

そこで、置き場所の幅を測って、その幅をそのまま viewBox の幅にします。座標の 1 が画面の 1px になるので、文字は fontSize の 12px のままです。棒の本数で幅を等分しているので、幅が狭くなると棒が細くなります。

tsx
import { useLayoutEffect, useRef, useState, type ComponentProps } from 'react';
import { BarChart } from './BarChart';

// 置き場所の幅を測り、その幅を viewBox の幅にする(拡大・縮小されないので文字の大きさが変わらない)
export function ResponsiveBarChart(props: Omit<ComponentProps<typeof BarChart>, 'width'>) {
  const ref = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState(0);

  useLayoutEffect(() => {
    const el = ref.current!;
    const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  return <div ref={ref}>{width > 0 && <BarChart {...props} width={width} />}</div>;
}

幅の変化は ResizeObserver で受け取ります。検証では、画面の幅を 800px から 360px に狭めると、viewBox が 0 0 768 280 から 0 0 328 280 に変わりました。なお、MDN の contentRect の説明によると、contentRect は対応しているブラウザが多い一方、以前の仕様の名残で、将来は非推奨になる可能性があります。この記事では contentBoxSize で書く方法は試していません。contentBoxSize を使った書き方は、このブログの「ReactでResizeObserverを使い、要素の幅で表示を変える」で扱っています。

データの切り替えを CSS の transition で動かす

年のボタンで表示するデータを切り替える画面を作りました。ResponsiveBarChart に渡すデータを useState で替えるだけです。

tsx
import { useState } from 'react';
import type { Datum } from './BarChart';
import { ResponsiveBarChart } from './ResponsiveBarChart';

// 例のデータ(架空の数字)
const datasets: Record<string, Datum[]> = {
  '2025年': [
    { label: '1月', value: 120 },
    { label: '2月', value: 95 },
    { label: '3月', value: 180 },
    { label: '4月', value: 140 },
    { label: '5月', value: 210 },
    { label: '6月', value: 160 },
  ],
  '2026年': [
    { label: '1月', value: 150 },
    { label: '2月', value: 130 },
    { label: '3月', value: 260 },
    { label: '4月', value: 190 },
    { label: '5月', value: 240 },
    { label: '6月', value: 310 },
  ],
};

export function App() {
  const [year, setYear] = useState('2025年');
  return (
    <section>
      <div role="group" aria-label="表示する年">
        {Object.keys(datasets).map((y) => (
          <button key={y} type="button" aria-pressed={y === year} onClick={() => setYear(y)}>
            {y}
          </button>
        ))}
      </div>
      <ResponsiveBarChart data={datasets[year]} title={`${year}の月ごとの件数`} />
    </section>
  );
}

棒をなめらかに伸び縮みさせるには、CSS に次の指定を足します。

css
.bar { transition: y 0.4s ease, height 0.4s ease; }
.value { transition: transform 0.4s ease; }
@media (prefers-reduced-motion: reduce) { .bar, .value { transition: none; } }

MDN の SVG の height 属性の説明によると、SVG 2 から <rect> の height は CSS のプロパティとしても使えます。y にも、同じ名前の CSS プロパティがあります。そのため、どちらも transition の対象にできます。検証環境の Chromium では、React が y と height の属性を書き換えただけで、棒が 0.4 秒かけて伸びました。6月の棒の高さは、切り替えの前が 143.4、約100ms 後が 155.7、約200ms 後が 167.6、約600ms 後が 173.6 です。

CSS の y プロパティに対応しているブラウザは、MDN の y プロパティのページにある互換性の表によると Chrome 42、Firefox 69、Safari 9 以降です。ただし、属性を書き換えたときに Firefox や Safari でも同じように動くかどうかは、確かめていません。

一方、値の文字(<text>)の y は、CSS では動かせません。MDN の y プロパティの説明にあるとおり、CSS の y が効くのは <rect>、<image>、<foreignObject>、入れ子の <svg> だけです。そこで値の文字は、style の transform で高さを決め、transform に transition を付けて、棒と一緒に動くようにしました。

2025年が表示されたグラフで、カーソルが「2026年」のボタンに移ってクリックすると、目盛りが 0〜250 から 0〜400 に変わり、棒が新しい高さまで伸び縮みする。変わった目盛りと値の数字が一時的にピンクになる。続けて「2025年」「2026年」と2回切り替える
年のボタンを押すと、棒が新しい値まで伸び縮みする様子(3回切り替え)

GIF の左上の説明と右上のクリックの回数、カーソル、押した位置の輪は、撮影のために重ねたものです。切り替わった数字をピンクにしているのも、撮影のときだけです。GIF は、動いている途中の transition を document.getAnimations() で止め、50ms ずつ進めながら1コマずつ撮りました(MDN の getAnimations の説明にあるとおり、CSS の transition もこの一覧に入ります)。

GIF を見ると分かるとおり、目盛りの数字と線は、クリックした瞬間に新しい値に替わります。目盛りには transition を付けていないので、棒が伸びている途中は、棒の高さと目盛りが合っていません。棒の動きを目盛りに合わせたいときは、目盛りの <g> の transform にも transition を付ける方法が考えられますが、この記事では試していません。

動きを減らす設定(prefers-reduced-motion)が有効なときは、transition: none にしています。Playwright でこの設定を有効にして切り替えると、約100ms 後にはもう、6月の棒の高さは最後の 173.6 になっていました。

ポイント

<rect> の y と height は、属性のまま書いても CSS の transition で動きました(Chromium で確認)。<text> には CSS の y が効かないので、transform で位置を決めると一緒に動かせます。

​Posted by 86