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

折れ線グラフをなぞると、点とツールチップが値についてくる

デモ

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

仕様

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

しくみと調整

折れ線グラフの上でポインタを動かすと、ポインタの横の位置からいちばん近い月を選び、縦の案内線・今期と前期の点・ツールチップをその月へ滑らせます。位置と、ツールチップに出す金額は 1 つのオブジェクトに持ち、gsap.to で今の値から次の月の値へ動かして、動くたびに描き直します。前期比もその金額から計算するので、移るあいだは数字が数えながら切り替わります。

点と案内線は 0.35 秒、ツールチップは 0.5 秒で移ります(どちらも power3.out)。この差のぶん吹き出しが少し遅れてついてくるので、ポインタに引っぱられているように見えます。金額は 0.45 秒で数えます。グラフの外から入ったときは、滑らせずにその月へ置いてから 0.2 秒で出し、外へ出たら 0.2 秒で消します。

月は素早く続けて変わるので、どのトゥイーンも overwrite を auto にして、前のトゥイーンの同じ値を止めます。止めないと、古い行き先へ向かう動きが残り、点が行ったり来たりします。ツールチップは高いほうの点の上に置き、画面の端でははみ出さない所まで寄せて、矢印だけが点を指し続けるようにします。上に入らない高さのときは、低いほうの点の下へ回します。

値は表としてページに書き、スクリプトはその表を読んで線を描きます。描いたあとの表は見えなくするだけにして、読み上げには残します。グラフの枠は焦点を受け、左右の矢印キーで月を移れます。ツールチップには role の status を付け、キーで移った月の値が読み上げられるようにします。狭い画面ではグラフを横にスクロールさせ、キーで選んだ月が見える所まで送ります。動きを減らす設定では、滑らせずにその場で切り替えます。

つまみは、点とツールチップが移る時間の差、数える時間、目盛りの上端です。時間の差を広げるほど、吹き出しが大きく遅れてついてきます。目盛りの上端を最高値の 1.4 倍ほどにしておくと、いちばん高い月でもツールチップが点の上に収まります。線を描いて見せる入り方ではなく、描き終えたグラフの値を 1 か月ずつ読ませるための動きで、売上や利用者数の推移を見比べるダッシュボードや、管理画面のレポートに向いています。

コード

HTML
<!-- data-max は目盛りの上端、data-step は目盛りの間隔 -->
<figure class="line-tip" data-max="2000" data-step="500">
  <figcaption class="line-tip__caption">
    <span class="line-tip__title">月ごとの売上</span>
    <span class="line-tip__note">2025年10月〜2026年9月・単位:万円</span>
    <span class="line-tip__legend">
      <span class="line-tip__key">今期</span>
      <span class="line-tip__key line-tip__key--prev">前期</span>
    </span>
  </figcaption>

  <!-- 値はこの表が正。JS が表から折れ線を描き、表は読み上げ用に残す(スクリプトが無いときは表のまま見える) -->
  <table class="line-tip__table">
    <thead>
      <tr><th scope="col">月</th><th scope="col">今期(万円)</th><th scope="col">前期(万円)</th></tr>
    </thead>
    <tbody>
      <tr><th scope="row">2025年10月</th><td>620</td><td>540</td></tr>
      <tr><th scope="row">2025年11月</th><td>710</td><td>600</td></tr>
      <tr><th scope="row">2025年12月</th><td>880</td><td>760</td></tr>
      <tr><th scope="row">2026年1月</th><td>690</td><td>720</td></tr>
      <tr><th scope="row">2026年2月</th><td>760</td><td>640</td></tr>
      <tr><th scope="row">2026年3月</th><td>840</td><td>700</td></tr>
      <tr><th scope="row">2026年4月</th><td>920</td><td>760</td></tr>
      <tr><th scope="row">2026年5月</th><td>1,010</td><td>820</td></tr>
      <tr><th scope="row">2026年6月</th><td>960</td><td>800</td></tr>
      <tr><th scope="row">2026年7月</th><td>1,120</td><td>880</td></tr>
      <tr><th scope="row">2026年8月</th><td>1,240</td><td>930</td></tr>
      <tr><th scope="row">2026年9月</th><td>1,380</td><td>1,010</td></tr>
    </tbody>
  </table>

  <!-- グラフの枠。JS が表から折れ線を描いて見せる。左右の矢印キーでも月を移れる -->
  <div class="line-tip__frame" tabindex="0" role="group" aria-label="月ごとの売上のグラフ。左右の矢印キーで月を移れます" hidden>
    <!-- role="status" なので、キーで月を移ると値が読み上げられる -->
    <div class="line-tip__tip" role="status">
      <span class="line-tip__date"></span>
      <span class="line-tip__row"><span class="line-tip__key">今期</span><b></b></span>
      <span class="line-tip__row"><span class="line-tip__key line-tip__key--prev">前期</span><b></b></span>
      <span class="line-tip__row line-tip__row--delta">前期比<b></b></span>
    </div>
  </div>
</figure>

CSS
.line-tip {
  --bg: #f2f1ed;
  --ink: #262626;
  --mute: #898989;
  --mute-small: #6b6b6b; /* 16px 未満の文字 */
  --grid: rgb(38 38 38 / 0.12);
  --now: #475c6a; /* 今期の線・点・面 */
  --tip-now: #93b2c9; /* ツールチップの中の、今期の線の見本 */
  --prev: #898989; /* 前期の点線 */
  --tip-bg: #262626; /* ツールチップは明暗を反転する */
  --tip-ink: #eeece6;
  --tip-mute: #a8a6a0;
  --up: #6ba37f; /* 前期比(暗い地の上の小さな文字でも読める色) */
  --down: #e07a62;
  /* 見えなくした表が、狭い画面で横にはみ出さないよう切る */
  position: relative;
  overflow: clip;
  margin: 0;
  padding: 40px clamp(16px, 5vw, 64px);
  background: var(--bg);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.line-tip__caption { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 24px; margin-bottom: 24px; }
.line-tip__title { flex-basis: 100%; font-size: clamp(28px, 4vw, 44px); font-weight: 800; letter-spacing: -0.02em; }
.line-tip__note { font-size: 16px; letter-spacing: 0.04em; color: var(--mute); }
.line-tip__legend { display: flex; gap: 16px; margin-left: auto; font-size: 14px; color: var(--mute-small); }

/* 凡例とツールチップの線の見本 */
.line-tip__key::before { content: ""; display: inline-block; width: 16px; margin-right: 8px; border-top: 3px solid var(--now); vertical-align: middle; }
.line-tip__key--prev::before { border-top: 3px dotted var(--prev); }
.line-tip__tip .line-tip__key:not(.line-tip__key--prev)::before { border-top-color: var(--tip-now); }

/* 狭い画面では、字が小さくなりすぎないよう、グラフを横にスクロールさせる */
.line-tip__frame { position: relative; overflow-x: auto; border-radius: 4px; }
.line-tip__frame:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.line-tip__plot { display: block; width: 100%; min-width: 560px; height: auto; }

.line-tip__grid { stroke: var(--grid); stroke-dasharray: 4 6; }
.line-tip__axis { stroke: var(--grid); stroke-width: 2; }
.line-tip__tick,
.line-tip__month { fill: var(--mute-small); font-size: 12px; }
.line-tip__tick { text-anchor: end; }
.line-tip__month { text-anchor: middle; }
.line-tip__area { fill: var(--now); fill-opacity: 0.08; }
.line-tip__now,
.line-tip__prev { fill: none; stroke-linecap: round; stroke-linejoin: round; }
.line-tip__now { stroke: var(--now); stroke-width: 3; }
.line-tip__prev { stroke: var(--prev); stroke-width: 2; stroke-dasharray: 5 6; }

/* 案内線・点・ツールチップは、なぞるまで隠しておく。下のグラフへポインタを通す */
.line-tip__guide,
.line-tip__dot,
.line-tip__tip { opacity: 0; pointer-events: none; }
.line-tip__guide { stroke: var(--ink); stroke-opacity: 0.35; }
.line-tip__dot { fill: var(--now); stroke: #ffffff; stroke-width: 2; }
.line-tip__dot--prev { fill: var(--bg); stroke: var(--prev); }

.line-tip__tip {
  position: absolute;
  left: 0;
  top: 0;
  display: grid;
  gap: 6px;
  width: 176px;
  box-sizing: border-box;
  padding: 12px 14px;
  border-radius: 4px;
  background: var(--tip-bg);
  color: var(--tip-ink);
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.04em;
}
/* 矢印は --arrow の位置(JS が点の x に合わせる)。点の下へ回ったときは上向きにする */
.line-tip__tip::after {
  content: "";
  position: absolute;
  top: 100%;
  left: var(--arrow, 50%);
  margin-left: -7px;
  border: 7px solid transparent;
  border-top-color: var(--tip-bg);
}
.line-tip__tip.is-below::after { top: auto; bottom: 100%; border-color: transparent transparent var(--tip-bg); }
.line-tip__date { font-size: 12px; font-weight: 400; color: var(--tip-mute); }
.line-tip__row { display: flex; justify-content: space-between; gap: 16px; }
.line-tip__row--delta { font-size: 12px; font-weight: 400; color: var(--tip-mute); }
.line-tip__row--delta b { color: var(--up); }
.line-tip__row--delta b.is-down { color: var(--down); }

/* 描いたあとの表は、読み上げ用に見えなくするだけ(display: none にすると、読み上げからも消える) */
.line-tip__table { border-collapse: collapse; font-size: 14px; }
.line-tip__table th,
.line-tip__table td { padding: 8px 24px 8px 0; border-bottom: 1px solid var(--grid); text-align: left; }
.line-tip.is-ready .line-tip__table { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

JavaScript
import { gsap } from "gsap";

const BOX = { width: 720, height: 400, left: 48, right: 16, top: 16, bottom: 36 }; // SVG の座標での大きさと余白
const GAP = 16; // ツールチップと点の間(px)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const time = (seconds) => (reduceMotion ? 0 : seconds); // 動きを減らす設定では、その場で切り替える
const yen = (v) => `${Math.round(v).toLocaleString("ja-JP")}万円`;
function el(parent, name, attrs = {}, text = "") {
  const node = parent.appendChild(document.createElementNS("http://www.w3.org/2000/svg", name));
  for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
  node.textContent = text;
  return node;
}
// 点を通るなめらかな線。前後の点から 3 次ベジェの制御点を決める(Catmull-Rom)
function curve(points) {
  return `M${points[0]}` + points.slice(1).map((p2, i) => {
    const [p0, p1, p3] = [points[i - 1] ?? points[i], points[i], points[i + 2] ?? p2];
    return `C${p1[0] + (p2[0] - p0[0]) / 6},${p1[1] + (p2[1] - p0[1]) / 6} ${p2[0] - (p3[0] - p1[0]) / 6},${p2[1] - (p3[1] - p1[1]) / 6} ${p2}`;
  }).join("");
}

document.querySelectorAll(".line-tip").forEach((chart) => {
  // 月と値は表から読む。値を持つ場所が 1 つなので、表を直せば線も直る
  const rows = [...chart.querySelectorAll("tbody tr")];
  const months = rows.map((row) => row.cells[0].textContent);
  const [now, prev] = [1, 2].map((c) => rows.map((row) => Number(row.cells[c].textContent.replace(/,/g, ""))));
  const { width, height, left, right, top, bottom } = BOX;
  const [max, step, last] = [Number(chart.dataset.max), (width - left - right) / (rows.length - 1), rows.length - 1];
  const x = (i) => left + step * i;
  const y = (v) => height - bottom - ((height - top - bottom) * v) / max;
  const [frame, tip] = [chart.querySelector(".line-tip__frame"), chart.querySelector(".line-tip__tip")];
  const [date, nowText, prevText, deltaText] = tip.querySelectorAll(".line-tip__date, b");
  const svg = el(frame, "svg", { class: "line-tip__plot", viewBox: `0 0 ${width} ${height}`, "aria-hidden": "true" });
  const scale = () => svg.clientWidth / width; // SVG は枠の幅に合わせて伸び縮みする
  for (let v = 0; v <= max; v += Number(chart.dataset.step)) {
    el(svg, "line", { class: v ? "line-tip__grid" : "line-tip__axis", x1: left, x2: width - right, y1: y(v), y2: y(v) });
    el(svg, "text", { class: "line-tip__tick", x: left - 10, y: y(v) + 4 }, v.toLocaleString("ja-JP"));
  }
  months.forEach((m, i) => el(svg, "text", { class: "line-tip__month", x: x(i), y: height - 12 }, m.replace(/^\d+年/, "")));
  const line = curve(now.map((v, i) => [x(i), y(v)]));
  el(svg, "path", { class: "line-tip__area", d: `${line}V${y(0)}H${x(0)}Z` });
  el(svg, "path", { class: "line-tip__prev", d: curve(prev.map((v, i) => [x(i), y(v)])) });
  el(svg, "path", { class: "line-tip__now", d: line });
  const guide = el(svg, "line", { class: "line-tip__guide", y1: top, y2: y(0) });
  const dot2 = el(svg, "circle", { class: "line-tip__dot line-tip__dot--prev", r: 4 });
  const dot1 = el(svg, "circle", { class: "line-tip__dot", r: 6 });
  frame.hidden = false;
  chart.classList.add("is-ready");
  // 案内線・点・ツールチップの位置(SVG の座標)と、数えている値。変わるたびに render で描く
  const state = { x: 0, y1: 0, y2: 0, tipX: 0, tipY: 0, v1: 0, v2: 0, alpha: 0 };
  const render = () => {
    const tx = gsap.utils.clamp(0, svg.clientWidth - tip.offsetWidth, state.tipX * scale() - tip.offsetWidth / 2);
    const arrow = gsap.utils.clamp(14, tip.offsetWidth - 14, state.tipX * scale() - tx); // 端で詰めても、矢印は点を指す
    gsap.set(tip, { x: tx, y: state.tipY * scale(), opacity: state.alpha, "--arrow": `${arrow}px` });
    gsap.set(guide, { attr: { x1: state.x, x2: state.x }, opacity: state.alpha });
    gsap.set(dot1, { attr: { cx: state.x, cy: state.y1 }, opacity: state.alpha });
    gsap.set(dot2, { attr: { cx: state.x, cy: state.y2 }, opacity: state.alpha });
    const rate = (Math.round(state.v1) / Math.round(state.v2) - 1) * 100; // 前期比も、数えている値から出す
    [nowText.textContent, prevText.textContent, deltaText.textContent] = [yen(state.v1), yen(state.v2), `${rate < 0 ? "−" : "+"}${Math.abs(rate).toFixed(1)}%`];
    deltaText.classList.toggle("is-down", rate < 0);
  };
  // どれも今の値から向かい直す(overwrite: "auto" で、前のトゥイーンの同じ値を止める)
  const go = (vars, duration, ease = "power3.out") => gsap.to(state, { ...vars, duration: time(duration), ease, overwrite: "auto", onUpdate: render });
  let index = -1;
  const show = (i) => {
    if (i === index) return;
    const enter = index < 0 && state.alpha < 0.05; // 外から入ったら、その月へ飛んでから出す
    index = i;
    date.textContent = months[i];
    const to = { x: x(i), y1: y(now[i]), y2: y(prev[i]) };
    // ツールチップは高いほうの点の上。上に入らないときは、低いほうの点の下へ回す
    const above = Math.min(to.y1, to.y2) - (tip.offsetHeight + GAP) / scale();
    tip.classList.toggle("is-below", above < 0);
    const at = { tipX: to.x, tipY: above < 0 ? Math.max(to.y1, to.y2) + GAP / scale() : above };
    if (enter) Object.assign(state, to, at, { v1: now[i], v2: prev[i] });
    go(to, 0.35); // 点と案内線
    go(at, 0.5); // ツールチップは少し遅れてついてくる
    go({ v1: now[i], v2: prev[i] }, 0.45, "power2.out");
    go({ alpha: 1 }, 0.2, "none");
  };
  const hide = () => { index = -1; go({ alpha: 0 }, 0.2, "none"); };
  const pick = (e) => show(gsap.utils.clamp(0, last, Math.round((((e.clientX - svg.getBoundingClientRect().left) / svg.clientWidth) * width - left) / step)));
  ["pointermove", "pointerdown"].forEach((type) => frame.addEventListener(type, pick)); // 指で触れたときも、その月を出す
  frame.addEventListener("pointerleave", (e) => e.pointerType !== "touch" && hide());
  // キーボードでは最後の月から出し、左右の矢印キーで移る。狭い画面では、選んだ月が見える所まで送る
  const byKey = (i) => {
    show(i);
    frame.scrollLeft = x(i) * scale() - frame.clientWidth / 2;
  };
  frame.addEventListener("focus", () => frame.matches(":focus-visible") && byKey(index < 0 ? last : index)); // マウスで押したときは除く
  frame.addEventListener("blur", hide);
  frame.addEventListener("keydown", (e) => {
    const move = { ArrowLeft: -1, ArrowRight: 1, Home: -last, End: last }[e.key];
    if (!move) return;
    e.preventDefault();
    byKey(gsap.utils.clamp(0, last, Math.max(index, 0) + move));
  });
});

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

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