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

割合の積み上げ横棒グラフが年ごとに並び、区分の移り変わりが見える

デモ

操作:カーソルを重ねる。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

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

しくみと調整

2022〜2026 年の集客の経路の割合を、年ごとの 100% 積み上げの横棒で並べます。棒は上の年から順に、左の区分から 1 つずつ伸び、伸びた区分には割合の数字が出ます。年と年の間では、同じ区分の境目どうしを細い線で結ぶので、境目の傾きを追うだけで、どの経路が増えてどれが減ったかが読めます。量の積み上げを面で見せるグラフや、順位を入れ替える横棒と違い、どの年も長さを 100% にそろえて、内訳の割合だけを比べます。

1 つの区分は 0.4 秒の power2.out で伸び、次の区分は 0.15 秒、次の年は 0.25 秒遅れて始めます。区分を 1 つずつ伸ばすと、棒の左から内訳を読む順序と、伸びる順序がそろいます。年の間の線は、下の年で境目の左の区分が伸びきったところから、上から下へ 0.3 秒で引きます。区分が伸びる前に線を引くと、まだ無い境目を指してしまいます。

凡例か棒の区分に重ねると、その区分だけ濃く残り、ほかの区分は 22% の濃さまで淡くなります。年の間には、その区分の上下の境目で囲んだ帯が区分の色で渡り、太さの移り変わりが流れのように見えます。区分どうしの間は 2px の地を空けて塗り分けます。間を空けないと、明るさの近い隣どうしが 1 本につながって見えます。年の間の線は幅に合わせて伸びるよう横を 0〜100 の座標で描き、線の太さは vector-effect で 1px に保ちます。

値は表としてページに書き、スクリプトはその表を読んで棒を組みます。組んだあとの表は見えなくするだけにして、読み上げに残します。凡例はボタンにして、焦点を当てたときと押したときにも同じ強調をかけ、その区分の年ごとの割合を見えない status に書きます。指で触れたときは、触れた区分を強調します。動きを減らす設定では、伸びきった状態で置きます。

つまみは、区分の色(左ほど明るく、明るさの差で見分けます)、伸びる時間と区分・年のずれ、淡くする濃さです。区分は 4〜5 つまでにし、細い区分が増えるときは「その他」にまとめます。集客の経路や売上の構成、アンケートの回答の割合が年や月でどう移ったかを見せる節に向いています。

コード

HTML
<figure class="mix">
  <figcaption class="mix__caption">
    <span class="mix__eyebrow">Channels</span>
    <span class="mix__title">集客の経路の移り変わり</span>
    <span class="mix__note"><span>2022〜2026年・</span><span>新規のお問い合わせの経路別の割合・</span><span>単位:%</span></span>
  </figcaption>

  <!-- 凡例に重ねる・焦点を当てる・押すと、その区分だけ濃く残す -->
  <ul class="mix__legend">
    <li><button type="button">検索</button></li>
    <li><button type="button">SNS</button></li>
    <li><button type="button">紹介</button></li>
    <li><button type="button">広告</button></li>
  </ul>

  <!-- 年ごとの棒はスクリプトが表から組む(絵なので読み上げからは外し、値は下の表で届ける) -->
  <div class="mix__chart" aria-hidden="true">
    <ol class="mix__rows"></ol>
    <ul class="mix__ticks">
      <li>0%</li>
      <li>25%</li>
      <li>50%</li>
      <li>75%</li>
      <li>100%</li>
    </ul>
  </div>
  <p class="mix__status" role="status"></p>

  <!-- 値はこの表が正。列は凡例と同じ順(左の区分から) -->
  <table class="mix__table">
    <caption>新規のお問い合わせの経路別の割合(%)</caption>
    <thead>
      <tr><th scope="col">年</th><th scope="col">検索</th><th scope="col">SNS</th><th scope="col">紹介</th><th scope="col">広告</th></tr>
    </thead>
    <tbody>
      <tr><th scope="row">2022年</th><td>48</td><td>14</td><td>16</td><td>22</td></tr>
      <tr><th scope="row">2023年</th><td>44</td><td>20</td><td>15</td><td>21</td></tr>
      <tr><th scope="row">2024年</th><td>39</td><td>27</td><td>15</td><td>19</td></tr>
      <tr><th scope="row">2025年</th><td>34</td><td>33</td><td>16</td><td>17</td></tr>
      <tr><th scope="row">2026年</th><td>30</td><td>38</td><td>17</td><td>15</td></tr>
    </tbody>
  </table>
</figure>

CSS
.mix {
  --bg: #111111;
  --surface: #262626;
  --ink: #eeece6;
  --mute: #898989;
  --mute-small: #a8a6a0; /* 16px 未満の文字 */
  --line: rgb(238 236 230 / 0.14);
  --link: rgb(238 236 230 / 0.45); /* 年の間の境目を結ぶ線 */
  /* 区分の色(左から)。左ほど明るく、明るさの差で見分ける */
  --c0: #f3dbb6;
  --c1: #efbc7c;
  --c2: #71a5cc;
  --c3: #5c91b3;
  --bar: 40px;
  --gap: 36px; /* 年と年の間(境目を結ぶ線を引く所) */
  --dim: 0.22; /* 強調していない区分の濃さ */
  position: relative;
  display: grid;
  gap: 24px;
  overflow: clip; /* 読み上げ用に隠した表が、狭い画面で横にはみ出さないように */
  margin: 0;
  padding: 48px clamp(16px, 5vw, 96px);
  background: var(--bg);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.mix__caption { display: grid; gap: 10px; }
.mix__eyebrow { color: var(--mute-small); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.mix__title { font-size: clamp(28px, 6vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.mix__note { color: var(--mute); font-size: 16px; letter-spacing: 0.04em; }
.mix__note span { display: inline-block; } /* 狭い画面でも、語の途中で折り返さない */

.mix__legend { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0; padding: 0; list-style: none; }
.mix__legend button { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 0; border-radius: 4px; background: none; color: var(--ink); font: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; cursor: pointer; }
.mix__legend button::before { content: ""; width: 14px; height: 14px; border-radius: 2px; background: var(--c); }
.mix__legend button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.mix [data-k="0"] { --c: var(--c0); }
.mix [data-k="1"] { --c: var(--c1); }
.mix [data-k="2"] { --c: var(--c2); }
.mix [data-k="3"] { --c: var(--c3); }

/* 年ごとの行: 年 | 棒。棒の下に、次の年との境目を結ぶ線の svg を置く */
.mix__rows { display: grid; gap: var(--gap); margin: 0; padding: 0; list-style: none; }
.mix__row { display: grid; grid-template-columns: 72px minmax(0, 1fr); align-items: center; }
.mix__year { font-size: 16px; font-weight: 700; letter-spacing: 0.04em; }
.mix__bar { position: relative; display: flex; gap: 2px; height: var(--bar); background: var(--surface); }
/* 区分どうしの間の 2px(3 か所)を 4 つの区分で等しく分けて引く */
.mix__seg { --g: 0; display: grid; place-items: center; overflow: hidden; width: calc((var(--v) * 1% - 1.5px) * var(--g)); background: var(--c); color: var(--bg); font-size: 15px; font-weight: 700; white-space: nowrap; transition: opacity 0.3s; }
.mix__seg span { opacity: 0; }
.mix__links { position: absolute; top: 100%; left: 0; width: 100%; height: var(--gap); overflow: visible; }
.mix__links line { stroke: var(--link); stroke-width: 1; transition: opacity 0.3s; }
.mix__links polygon { fill: var(--c); opacity: 0; transition: opacity 0.3s; }

/* 強調: 選んだ区分だけ濃く残し、年の間に区分の色の帯を渡す */
.mix.is-focus .mix__seg:not(.is-active),
.mix.is-focus .mix__legend button:not(.is-active),
.mix.is-focus .mix__links line:not(.is-active) { opacity: var(--dim); }
.mix.is-focus .mix__links polygon.is-active { opacity: 0.32; }

.mix__ticks { position: relative; height: 16px; margin: 12px 0 0 72px; padding: 0; color: var(--mute-small); font-size: 13px; list-style: none; }
.mix__ticks li { position: absolute; left: calc(var(--k) * 25%); translate: -50%; }
.mix__ticks li:first-child { translate: 0; }
.mix__ticks li:last-child { translate: -100%; }
.mix__ticks li:nth-child(2) { --k: 1; }
.mix__ticks li:nth-child(3) { --k: 2; }
.mix__ticks li:nth-child(4) { --k: 3; }
.mix__ticks li:nth-child(5) { --k: 4; }

@media (max-width: 640px) {
  .mix { --bar: 32px; --gap: 28px; }
  .mix__row { grid-template-columns: 56px minmax(0, 1fr); }
  .mix__year { font-size: 13px; }
  .mix__seg { font-size: 12px; }
  .mix__ticks { margin-left: 56px; }
}

@media (prefers-reduced-motion: reduce) {
  .mix__seg, .mix__links line, .mix__links polygon { transition: none; }
}

/* 読み上げだけに届ける。表は、スクリプトが棒を組むまで見えたまま残す */
.mix__status,
.mix.is-live .mix__table { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.mix__table { width: 100%; border-spacing: 0; }
.mix__table caption { padding-bottom: 12px; color: var(--mute-small); font-size: 13px; text-align: left; }
.mix__table th,
.mix__table td { padding: 10px 12px; border-top: 1px solid var(--line); font-size: 14px; text-align: right; }
.mix__table th:first-child { text-align: left; }
.mix__table thead th { border: 0; color: var(--mute-small); font-size: 13px; font-weight: 600; }

JavaScript
import { gsap } from "gsap";

// 動きを減らす設定では、伸びきった絵で置く
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SVG_NS = "http://www.w3.org/2000/svg";
const make = (tag, className, parent) => parent.appendChild(Object.assign(document.createElement(tag), { className }));
const svgNode = (tag, parent, attrs) => {
  const el = parent.appendChild(document.createElementNS(SVG_NS, tag));
  for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, value);
  return el;
};

document.querySelectorAll(".mix").forEach((chart) => {
  const list = chart.querySelector(".mix__rows");
  const status = chart.querySelector(".mix__status");
  const buttons = [...chart.querySelectorAll(".mix__legend button")];
  const names = [...chart.querySelectorAll("thead th")].slice(1).map((th) => th.textContent);
  // 値は表が正。年ごとに、左の区分からの割合と、累計の境目(%)を持つ
  const rows = [...chart.querySelectorAll("tbody tr")].map((tr) => {
    const values = [...tr.cells].slice(1).map((td) => Number(td.textContent));
    let sum = 0;
    const edges = [0, ...values.map((v) => (sum += v))];
    const li = make("li", "mix__row", list);
    make("span", "mix__year", li).textContent = tr.cells[0].textContent;
    const bar = make("span", "mix__bar", li);
    const segs = values.map((v, k) => {
      const seg = make("span", "mix__seg", bar);
      seg.dataset.k = k;
      seg.style.setProperty("--v", v);
      make("span", "", seg).textContent = `${v}%`;
      return seg;
    });
    return { year: tr.cells[0].textContent, values, edges, bar, segs };
  });
  // 年の間: 同じ区分の境目どうしを結ぶ線と、区分の帯。幅に合わせて伸びるよう、横は 0〜100 の座標で描く
  const links = [];
  rows.slice(0, -1).forEach((row, i) => {
    const next = rows[i + 1];
    const svg = svgNode("svg", row.bar, { class: "mix__links", viewBox: "0 0 100 1", preserveAspectRatio: "none" });
    names.forEach((_, k) => {
      const points = `${row.edges[k]},0 ${row.edges[k + 1]},0 ${next.edges[k + 1]},1 ${next.edges[k]},1`;
      svgNode("polygon", svg, { points, "data-k": k });
    });
    for (let k = 1; k < names.length; k++) {
      const x = row.edges[k];
      const line = svgNode("line", svg, { x1: x, y1: 0, x2: x, y2: 0, "vector-effect": "non-scaling-stroke" });
      line.dataset.edge = k; // 左右の区分(k − 1 と k)のどちらを強調しても残す
      links.push({ line, i, x2: next.edges[k] });
    }
  });
  buttons.forEach((button, k) => (button.dataset.k = k));
  chart.classList.add("is-live"); // ここから先は、表は読み上げにだけ残す

  // 画面に入ったら 1 回だけ。上の年から、左の区分から順に伸ばし、下の年に境目ができたら年の間に線を引く
  const play = () => {
    const tl = gsap.timeline();
    rows.forEach((row, i) => row.segs.forEach((seg, k) => {
      const at = i * 0.25 + k * 0.15;
      tl.to(seg, { "--g": 1, duration: 0.4, ease: "power2.out" }, at); // 幅は CSS が --v(割合)と --g(伸び)から出す
      tl.to(seg.firstChild, { opacity: 1, duration: 0.15 }, at + 0.3);
    }));
    for (const { line, i, x2 } of links) {
      tl.to(line, { attr: { x2, y2: 1 }, duration: 0.3, ease: "power1.inOut" }, (i + 1) * 0.25 + 0.15 * (line.dataset.edge - 1) + 0.4);
    }
    if (reduceMotion) tl.progress(1);
  };
  const io = new IntersectionObserver(([entry]) => entry.isIntersecting && (io.disconnect(), play()), { rootMargin: "0px 0px -20%" });
  io.observe(chart);

  // 強調: 選んだ区分だけ濃く残し、年の間に区分の帯を出す。読み上げには、その区分の年ごとの割合を届ける
  let active = -1;
  const show = (k) => {
    if (k === active) return;
    active = k;
    chart.classList.toggle("is-focus", k >= 0);
    chart.querySelectorAll("[data-k]").forEach((el) => el.classList.toggle("is-active", Number(el.dataset.k) === k));
    for (const { line } of links) line.classList.toggle("is-active", k === line.dataset.edge - 1 || k === Number(line.dataset.edge));
    status.textContent = k < 0 ? "" : `${names[k]}の割合 ${rows.map((row) => `${row.year} ${row.values[k]}%`).join("、")}`;
  };
  const targets = [...buttons, ...rows.flatMap((row) => row.segs)];
  for (const el of targets) {
    const k = Number(el.dataset.k);
    el.addEventListener("pointerenter", (e) => e.pointerType !== "touch" && show(k));
    el.addEventListener("pointerleave", (e) => e.pointerType !== "touch" && show(-1));
  }
  // 指で触れたとき・キーボードで押したときは、押した区分を出す(もう一度押すと戻す)
  buttons.forEach((button, k) => {
    button.addEventListener("click", () => show(active === k ? -1 : k));
    button.addEventListener("focus", () => button.matches(":focus-visible") && show(k));
    button.addEventListener("blur", () => show(-1));
  });
  rows.forEach((row) => row.segs.forEach((seg, k) => seg.addEventListener("pointerdown", (e) => e.pointerType === "touch" && show(k))));
});

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

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