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

グラフ

ウォーターフォールのグラフの段が順に伸び、増減の積み上がりが見える

デモ

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

仕様

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

しくみと調整

前年度の棒が立ち上がると、そこから右へ順に、増えた要因は緑で上へ、減った要因はバーミリオンカラーで下へ、前の段が届いた高さを起点に伸びていきます。段と段のあいだは高さを受け渡す点線でつながり、最後に今年度の合計の棒が下から立つので、始まりと終わりの高さを見比べられます。

棒の描き方は、すべて同じ計算にそろえました。どの棒も「始まりの値から終わりの値へ、進み具合に合わせて伸ばす」だけで、前年度と合計は0から、増減の段は前の段の累計から伸ばします。上向きと下向きで処理を分ける必要はありません。棒が伸びるのは0.5秒、次の段は0.45秒遅れて始まり、点線はその段が伸び終わるころに引かれます。

値のラベルは、増えた段と合計では棒の上に、減った段では棒の下に置いています。全部を上にそろえると、減った段では前から届く点線とラベルが重なって読めません。文字は棒と同じ色にせず、一段明るい緑と朱にして、暗い背景の上でも数字が読めるようにしています。棒の上下の辺と点線の位置は、整数のピクセルに丸めてあります。半端な位置のままだと、描き直すたびに縁の1ピクセルの濃さが揺らぎます。

段にカーソルを重ねると、その段までの累計の高さへ破線の目安が横に伸び、右側に累計額のラベルが現れます。目安の線は段ごとに1本ずつ用意して、重ねた段の線だけを見せているので、段を移っても線が飛び移るように動きません。キーボードではグラフの領域にフォーカスを当て、左右の矢印キーで段を移りながら、それぞれの増減と累計を確かめられます。

目盛りの上端の置き方ひとつで、段の読みやすさが変わります。大きくしすぎるとグラフ全体が低くなって小さな要因の段がつぶれ、いちばん高い累計に近づけすぎると、棒の上に置いたラベルの余白がなくなります。要因は5つまでにとどめ、細かいものは「その他」にまとめています。

コード

HTML
<figure class="fall" data-max="2500">
  <figcaption class="fall__caption">
    <span class="fall__eyebrow">Revenue bridge</span>
    <span class="fall__title">売上を動かした要因</span>
    <span class="fall__note"><span>2026年度・売上の増減要因・</span><span>単位:万円</span></span>
  </figcaption>

  <!-- 目盛りと項目名は HTML に置き、棒と点線だけを JS が描く。グラフの要素はフォーカスを受け、矢印キーで要因を移れる -->
  <div class="fall__plot">
    <div class="fall__grid" tabindex="0" role="group" aria-label="売上の増減要因のグラフ。左右の矢印キーで要因を移れます">
      <svg class="fall__svg" aria-hidden="true"></svg>
      <ul class="fall__y" aria-hidden="true">
        <li style="--v: 1">2,500</li>
        <li style="--v: 0.8">2,000</li>
        <li style="--v: 0.6">1,500</li>
        <li style="--v: 0.4">1,000</li>
        <li style="--v: 0.2">500</li>
        <li style="--v: 0">0</li>
      </ul>
      <ul class="fall__x" aria-hidden="true">
        <li style="--i: 0">前年度</li>
        <li style="--i: 1">新規顧客</li>
        <li style="--i: 2">単価改善</li>
        <li style="--i: 3">解約</li>
        <li style="--i: 4">為替</li>
        <li style="--i: 5">その他</li>
        <li style="--i: 6">今年度</li>
      </ul>
    </div>
  </div>
  <p class="fall__status" role="status"></p>

  <!-- 値はこの表に書く。最初と最後の行は合計の棒、あいだの行は前の累計から次の累計へ伸びる棒になる -->
  <table class="fall__table">
    <caption>2026年度の売上の増減要因(万円)</caption>
    <thead>
      <tr><th scope="col">項目</th><th scope="col">増減</th><th scope="col">累計</th></tr>
    </thead>
    <tbody>
      <tr><th scope="row">前年度</th><td>—</td><td>1,200</td></tr>
      <tr><th scope="row">新規顧客</th><td>+680</td><td>1,880</td></tr>
      <tr><th scope="row">単価改善</th><td>+420</td><td>2,300</td></tr>
      <tr><th scope="row">解約</th><td>−320</td><td>1,980</td></tr>
      <tr><th scope="row">為替</th><td>−220</td><td>1,760</td></tr>
      <tr><th scope="row">その他</th><td>+240</td><td>2,000</td></tr>
      <tr><th scope="row">今年度</th><td>—</td><td>2,000</td></tr>
    </tbody>
  </table>
</figure>

CSS
.fall {
  --bg: #111111;
  --ink: #eeece6;
  --mute: #898989;
  --mute-small: #a8a6a0; /* 16px 未満の文字 */
  --line: rgb(238 236 230 / 0.14);
  --total: #93b2c9; /* 前年度・今年度の棒 */
  --up: #4f8a64; /* 増えた要因(成功の緑) */
  --down: #c4553d; /* 減った要因(エラーの色) */
  --total-text: var(--total); /* 値のラベルの文字色。増減は、暗い背景でも読める明るい色 */
  --up-text: #6ba37f;
  --down-text: #e07a62;
  --height: 340px; /* グラフの高さ */
  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;
}

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

/* 左に目盛り、下に項目名、右に累計のラベルを置く余白 */
.fall__plot { padding: 0 124px 34px 52px; }
.fall__grid {
  position: relative;
  height: var(--height);
  /* 目盛りの線。下端から 20% ごとに 1px(上端の線だけは影で引く) */
  background-image: repeating-linear-gradient(to top, var(--line) 0 1px, transparent 1px 20%);
  box-shadow: inset 0 1px 0 var(--line);
}
.fall__grid:focus-visible { outline: 2px solid var(--ink); outline-offset: 6px; }
.fall__svg { display: block; width: 100%; height: 100%; }
.fall__svg text { font-size: 16px; font-weight: 700; letter-spacing: -0.01em; text-anchor: middle; }
.fall__svg .fall__dot { stroke: var(--mute); stroke-dasharray: 2 5; stroke-linecap: round; }
.fall__svg .fall__guide { stroke: var(--ink); stroke-dasharray: 5 5; }
.fall__svg .fall__hit { fill: transparent; cursor: pointer; }

.fall__y,
.fall__x { margin: 0; padding: 0; color: var(--mute-small); font-size: 13px; list-style: none; }
.fall__y li { position: absolute; right: calc(100% + 10px); bottom: calc(var(--v) * 100%); transform: translateY(50%); }
.fall__x li { position: absolute; top: 100%; left: calc((var(--i) + 0.5) / 7 * 100%); transform: translate(-50%, 12px); white-space: nowrap; }

/* 累計のラベル。目盛りの右の外に置く */
.fall__chip {
  position: absolute;
  left: 100%;
  margin: 0 0 0 12px;
  padding: 4px 12px;
  border-radius: 4px;
  background: var(--ink);
  color: var(--bg);
  font-size: 14px;
  font-weight: 700;
  letter-spacing: 0.04em;
  white-space: nowrap;
  opacity: 0;
  transform: translateY(-50%);
  pointer-events: none;
}

/* 狭い画面では項目名を縦組みにし、累計のラベルはグラフの中の右端へ寄せる */
@media (max-width: 640px) {
  .fall { --height: 280px; }
  .fall__plot { padding: 0 0 72px 44px; }
  .fall__x li { writing-mode: vertical-rl; }
  .fall__svg text { font-size: 13px; }
  .fall__value--total { display: none; } /* 前年度と今年度の額は列に入らないので、表とラベルに任せる */
  .fall__chip { left: auto; right: 0; margin: 0; }
}

/* 読み上げだけに届ける。表は、スクリプトが棒を描くまで見えたまま残す */
.fall__status,
.fall.is-live .fall__table { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

.fall__table { width: 100%; border-spacing: 0; }
.fall__table caption { padding-bottom: 12px; color: var(--mute-small); font-size: 13px; text-align: left; }
.fall__table th,
.fall__table td { padding: 10px 12px; border-top: 1px solid var(--line); font-size: 14px; text-align: right; }
.fall__table th:first-child { text-align: left; }
.fall__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 time = (seconds) => (reduceMotion ? 0 : seconds);
const attr = (el, values) => { for (const [key, value] of Object.entries(values)) el.setAttribute(key, value); };
const value = (cell) => Number(cell.textContent.replace(/[^\d.]/g, ""));
const add = (svg, tag, className) => { const el = document.createElementNS("http://www.w3.org/2000/svg", tag); el.setAttribute("class", className); return svg.appendChild(el); };

document.querySelectorAll(".fall").forEach((chart) => {
  const [svg, grid] = [chart.querySelector(".fall__svg"), chart.querySelector(".fall__grid")];
  const status = chart.querySelector(".fall__status");
  const max = Number(chart.dataset.max);
  const color = (name) => getComputedStyle(chart).getPropertyValue(`--${name}`);
  // 値は表から読む。最初と最後の行は 0 から立つ合計の棒、あいだの行は前の累計から自分の累計へ伸びる棒
  const rows = [...chart.querySelectorAll("tbody tr")];
  const bars = rows.map((row, i) => {
    const total = i === 0 || i === rows.length - 1;
    const from = total ? 0 : value(rows[i - 1].cells[2]);
    const to = value(row.cells[2]);
    const bar = { name: row.cells[0].textContent, sum: row.cells[2].textContent, from, to, total, up: total || to > from, g: 0, lb: 0, c: 0, h: 0 };
    bar.rect = add(svg, "rect", "fall__bar");
    bar.text = Object.assign(add(svg, "text", total ? "fall__value fall__value--total" : "fall__value"), { textContent: total ? `${bar.sum}万円` : row.cells[1].textContent });
    const kind = total ? "total" : bar.up ? "up" : "down"; // 文字色は --〇〇-text(暗い背景でも読める明るい色)
    [bar.rect.style.fill, bar.text.style.fill] = [color(kind), color(`${kind}-text`)];
    bar.guide = add(svg, "line", "fall__guide");
    if (i < rows.length - 1) bar.dot = add(svg, "line", "fall__dot"); // 次の棒へ高さをつなぐ点線
    bar.chip = grid.appendChild(Object.assign(document.createElement("p"), { className: "fall__chip", textContent: `${bar.sum}万円` }));
    return bar;
  });
  const hits = bars.map(() => add(svg, "rect", "fall__hit")); // 当たり判定は列いっぱい(細い棒も押しやすい)
  chart.classList.add("is-live"); // ここから先は、表は読み上げにだけ残す

  let [w, h] = [0, 0];
  const yOf = (v) => h - (v / max) * h;
  const draw = () => {
    const slot = w / bars.length;
    const width = Math.round(Math.min(88, slot * 0.62));
    bars.forEach((bar, i) => {
      const x = Math.round(slot * i + (slot - width) / 2);
      const level = Math.round(yOf(bar.to)) + 0.5; // 1px の線は、画素の境目をまたがないよう 0.5 ずらす
      // 始まりの値から終わりの値へ伸ばす(増は上向き、減は下向き)
      const now = bar.from + (bar.to - bar.from) * bar.g;
      const top = Math.round(yOf(Math.max(bar.from, now)));
      attr(bar.rect, { x, width, rx: 2, y: top, height: Math.max(0, Math.round(yOf(Math.min(bar.from, now))) - top) });
      // 値のラベルは、増えた棒と合計は棒の上、減った棒は棒の下。現れるときに 8px 移動する
      attr(bar.text, { x: x + width / 2, opacity: bar.lb.toFixed(3), y: Math.round(bar.up ? yOf(Math.max(bar.from, bar.to)) - 12 + (1 - bar.lb) * 8 : yOf(Math.min(bar.from, bar.to)) + 26 - (1 - bar.lb) * 8) });
      if (bar.dot) attr(bar.dot, { x1: x + width, y1: level, y2: level, x2: Math.round(x + width + (slot - width) * bar.c) });
      attr(bar.guide, { x1: 0, y1: level, y2: level, x2: Math.round(w * bar.h), opacity: bar.h.toFixed(3) });
      attr(hits[i], { x: Math.round(slot * i), y: 0, width: Math.round(slot), height: h });
      bar.chip.style.cssText = `top:${Math.round(yOf(bar.to))}px;opacity:${bar.h}`;
    });
  };
  new ResizeObserver(() => {
    ({ width: w, height: h } = grid.getBoundingClientRect());
    svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
    draw();
  }).observe(grid);
  // 画面に入ったら 1 回だけ伸ばす。合計の棒は、増減の棒が出そろってから立てる
  const play = () => {
    const tl = gsap.timeline({ onUpdate: draw });
    bars.forEach((bar, i) => {
      const at = i === 0 ? 0 : i === bars.length - 1 ? 3.15 : 0.7 + 0.45 * (i - 1);
      tl.to(bar, { g: 1, duration: i === bars.length - 1 ? 0.7 : 0.5, ease: "power3.out" }, at)
        .to(bar, { lb: 1, duration: 0.3 }, at + 0.15)
        .to(bar, { c: 1, duration: 0.2, ease: "none" }, at + 0.35);
    });
    if (reduceMotion) tl.progress(1); // 動きを減らす設定では、出そろった状態で表示する
  };
  const io = new IntersectionObserver(([entry]) => {
    if (!entry.isIntersecting) return;
    io.disconnect();
    play();
  }, { rootMargin: "0px 0px -20%" });
  io.observe(chart);
  // ポインタを重ねた棒の累計の高さに、目安の線とラベルを表示する
  let active = -1;
  const show = (index) => {
    if (index === active) return;
    active = index;
    gsap.to(bars, { h: (i) => (i === index ? 1 : 0), duration: time(0.25), ease: "power2.out", overwrite: "auto", onUpdate: draw });
    const bar = bars[index];
    status.textContent = bar ? `${bar.name} ${bar.total ? "" : `${bar.text.textContent}万円、`}累計${bar.sum}万円` : "";
  };
  hits.forEach((hit, i) => {
    hit.addEventListener("pointerenter", (e) => e.pointerType !== "touch" && show(i));
    hit.addEventListener("pointerdown", () => show(i)); // タッチしたときも、その棒の目安の線とラベルを表示する
  });
  svg.addEventListener("pointerleave", (e) => e.pointerType !== "touch" && show(-1));
  // キーボードでは最初の棒から表示し、矢印キーで移る。Esc で閉じる
  grid.addEventListener("focus", () => grid.matches(":focus-visible") && show(Math.max(active, 0)));
  grid.addEventListener("blur", () => show(-1));
  grid.addEventListener("keydown", (e) => {
    const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
    if (!step) return e.key === "Escape" ? show(-1) : undefined;
    e.preventDefault();
    show(active < 0 ? 0 : (active + step + bars.length) % bars.length);
  });
});

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

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