本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
グラフ

ドーナツグラフが区分ごとに順に埋まり、中央の数字が増える

デモ

仕様

用途
グラフ
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

流入元の割合を、ドーナツの区分として 12 時から時計回りに 1 つずつ埋めていきます。区分はそれぞれ 1 本の円で、見えている長さと始まりの位置だけを書き換えて弧にします。円に、線の長さを 100 として扱わせる指定(pathLength)を付けておくと、割合の % をそのまま長さとして書けます。中央の合計と、表の割合とセッション数も、区分と一緒に 0 から数え上げます。

1 つの区分を埋める時間は、0.35 秒に「割合 × 1.4 秒」を足したもので、大きい区分ほど長くかかります。次の区分は前の区分が 8 割まで進んだところから始めるので、途切れずにひと続きで一周します。区分は power2.inOut で埋め、合計は全体の時間をかけて power2.out で数えます。区分の間には円周の 0.5% ほどのすき間を空けて、色の境目を読みやすくします。

見えている長さの指定(stroke-dasharray)は、CSS に書かないでください。CSS は SVG の属性より強いので、スクリプトで書き換えても効かず、弧がいつまでも動きません。値は要素の style に直接書きます。

グラフの見た目だけでは、読み上げに何も届きません。流入元・セッション数・割合は表としてページに書き、スクリプトはその表を読んで輪を描きます。値を持つ場所が表だけなので、数字を直せば輪も直ります。表の行は透明度だけで出し、出る前から値が読み上げに届くようにします。描くのは画面に入った 1 回だけで、動きを減らす設定では埋まりきった状態で見せます。

輪の直径と太さ、区分の色は CSS の値で変えられます。色は大きい区分から濃い → 淡いの順に明るさを離して並べると、隣り合う区分を色相に頼らず見分けられます。区分が多いと細い弧が並んで読めなくなるので、5 つほどまでにして、残りは「その他」にまとめます。アクセス解析の流入元や売上の内訳、アンケートの結果のように、全体に対する割合を見せたい節に向いています。

コード

HTML
<figure class="donut">
  <figcaption class="donut__caption">
    <span class="donut__eyebrow">Traffic sources</span>
    <span class="donut__title">流入元の内訳</span>
  </figcaption>

  <div class="donut__body">
    <div class="donut__chart">
      <!-- 区分の輪は、下の表の値から JS が描く -->
      <svg class="donut__svg" viewBox="0 0 200 200" aria-hidden="true">
        <circle class="donut__track" cx="100" cy="100" r="80" />
      </svg>
      <p class="donut__total"><b class="donut__sum">48,210</b>今月のセッション数</p>
    </div>

    <!-- 値はこの表が正。区分の割合と色(--color)も、ここから読む -->
    <table class="donut__table">
      <thead>
        <tr><th scope="col">流入元</th><th scope="col">セッション数</th><th scope="col">割合</th></tr>
      </thead>
      <tbody>
        <tr style="--color: #475c6a"><th scope="row">自然検索</th><td>20,248</td><td>42%</td></tr>
        <tr style="--color: #62839d"><th scope="row">直接アクセス</th><td>12,535</td><td>26%</td></tr>
        <tr style="--color: #c3ab90"><th scope="row">SNS</th><td>8,678</td><td>18%</td></tr>
        <tr style="--color: #d5c3af"><th scope="row">外部サイト</th><td>6,749</td><td>14%</td></tr>
      </tbody>
    </table>
  </div>
</figure>

CSS
.donut {
  --bg: #f2f1ed;
  --ink: #262626;
  --mute: #898989;                /* 中央の注記 */
  --mute-small: #6b6b6b;          /* 16px 未満の文字 */
  --line: rgba(38, 38, 38, 0.12); /* 輪の下地と、行の区切り */
  --size: 320px;                  /* 輪の直径 */
  --stroke: 24;                   /* 輪の太さ(viewBox 200 の中の値) */
  margin: 0;
  padding: 48px clamp(24px, 6vw, 96px);
  background: var(--bg);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.donut__caption { display: grid; gap: 16px; margin-bottom: 40px; }
.donut__eyebrow { color: var(--mute-small); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.donut__title { font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }

.donut__body {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 48px 64px;
}

.donut__chart { position: relative; flex: none; width: min(var(--size), 100%); }
.donut__svg { display: block; width: 100%; height: auto; }

/* 区分の見える長さは JS が要素の style に書く。ここで stroke-dasharray を宣言しない */
.donut__track,
.donut__arc { fill: none; stroke-width: var(--stroke); }
.donut__track { stroke: var(--line); }

.donut__total {
  position: absolute;
  inset: 0;
  display: grid;
  place-content: center;
  gap: 8px;
  margin: 0;
  color: var(--mute);
  font-size: 14px;
  letter-spacing: 0.04em;
  text-align: center;
}

.donut__sum { color: var(--ink); font-size: 48px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; }

/* 区切り線はセルに持たせ、行と一緒に出す(collapse にすると線は表が描くので、行より先に見える) */
.donut__table { flex: 1 1 320px; border-spacing: 0; }

.donut__table th,
.donut__table td { padding: 16px 0 16px 24px; border-top: 1px solid var(--line); text-align: right; white-space: nowrap; }
.donut__table th:first-child { padding-left: 0; text-align: left; }
.donut__table thead th { padding-block: 0 12px; border: 0; color: var(--mute-small); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; }

.donut__table tbody th { font-size: 18px; font-weight: 600; letter-spacing: 0.04em; }
.donut__table td { color: var(--mute-small); font-size: 14px; }
.donut__table td:last-child { color: var(--ink); font-size: 26px; font-weight: 800; letter-spacing: -0.02em; }

/* 行の色の四角は、行に書いた --color から */
.donut__table tbody th::before {
  content: "";
  display: inline-block;
  width: 16px;
  height: 16px;
  margin-right: 16px;
  border-radius: 4px;
  background: var(--color);
  vertical-align: -2px;
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const GAP = 0.5; // 区分の間のすき間(円周を 100 としたときの長さ)
const EASE = "power2.inOut";
const format = (v) => Math.round(v).toLocaleString("ja-JP");

document.querySelectorAll(".donut").forEach((chart) => {
  const svg = chart.querySelector(".donut__svg");
  const sum = chart.querySelector(".donut__sum");
  // 値は表から読む。値を持つ場所が 1 つなので、表を直せば輪も数字も直る
  const rows = [...chart.querySelectorAll("tbody tr")].map((row) => {
    const [count, share] = [row.cells[1], row.cells[2]].map((cell) => Number(cell.textContent.replace(/[^\d.]/g, "")));
    return { row, count, share, fill: 0 };
  });
  const total = rows.reduce((all, r) => all + r.count, 0);
  const state = { total: 0 };

  // 区分ごとに円を 1 本ずつ重ねる。線の長さを 100 として扱わせると、割合の % をそのまま長さとして書ける
  let start = 0;
  for (const r of rows) {
    r.start = start;
    start += r.share;
    r.arc = document.createElementNS("http://www.w3.org/2000/svg", "circle");
    const attrs = { class: "donut__arc", cx: 100, cy: 100, r: 80, pathLength: 100, transform: "rotate(-90 100 100)" };
    for (const [key, value] of Object.entries(attrs)) r.arc.setAttribute(key, value);
    r.arc.style.stroke = getComputedStyle(r.row).getPropertyValue("--color");
    r.arc.style.strokeDasharray = "0 100"; // 描き始めるまでは空にしておく
    svg.append(r.arc);
  }

  // dasharray と dashoffset は要素の style に書く(CSS で宣言すると、CSS が勝って動かない)
  const draw = () => {
    for (const r of rows) {
      const length = Math.max(0, r.fill - GAP);
      r.arc.style.strokeDasharray = `${length} 100`;
      r.arc.style.strokeDashoffset = String(-(r.start + GAP / 2));
      r.row.cells[1].textContent = format((r.fill / r.share) * r.count);
      r.row.cells[2].textContent = `${Math.round(r.fill)}%`;
    }
    sum.textContent = format(state.total);
  };

  // 大きい区分ほど長く埋め、次の区分は前の区分の 8 割の時点から始める
  const tl = gsap.timeline({ paused: true, onUpdate: draw });
  let at = 0;
  let end = 0;
  for (const r of rows) {
    const duration = 0.35 + (r.share / 100) * 1.4;
    tl.to(r, { fill: r.share, duration, ease: EASE }, at)
      // 行は透明度だけで出す(visibility で隠すと、描く前の値が読み上げに届かない)
      .from(r.row, { opacity: 0, x: 18, duration: 0.55, ease: "power3.out" }, at);
    end = at + duration;
    at += duration * 0.8;
  }
  tl.to(state, { total, duration: end, ease: "power2.out" }, 0).from(sum.parentElement, { opacity: 0, duration: 0.4 }, 0);

  // 動きを減らす設定では、埋まりきった状態で見せる
  if (reduceMotion) {
    tl.progress(1);
    return;
  }
  // 画面に入ったら 1 回だけ描く。スクロールのたびに描き直すと、値を追えない
  const io = new IntersectionObserver(([entry]) => {
    if (!entry.isIntersecting) return;
    io.disconnect();
    tl.play();
  }, { rootMargin: "0px 0px -20%" }); // 下から 2 割入ったら始める
  io.observe(chart);
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。