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

KPI カードの数字と前月比が現れ、スパークラインのグラフが描かれる

デモ

仕様

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

しくみと調整

ダッシュボードの KPI カードを左から順に浮かび上がらせ、数字を 0 から数え上げます。数えている間は単位の手前の数字だけを書き換え、桁区切りと小数の桁を付けて描きます。数え終わるころに前月比のバッジを出し、カードの下には 12 か月の推移を小さな折れ線(スパークライン)で左から描いて、終点に点を置きます。

カードは 0.12 秒ずつずらし、それぞれ 0.7 秒で 28px 下から浮かせます。数字は 1.3 秒の power2.out で数えるので、終わりに近づくほど遅くなり、最後の桁が読み取れます。小数の桁は書いてある値に合わせて毎回そろえ、数字を等幅にしておくと、数える途中でも幅が変わりません。線は 1.1 秒かけて描き、描き終わりに線の下の面を薄く出して、終点の点を弾ませます。

線は DrawSVGPlugin で、見えている長さを 0% から 100% へ伸ばして描きます。このとき線の端を丸くすると、長さが 0 でも始点に点が残り、描き始める前から小さな点が見えます。線の端は平ら(butt)のままにします。

数字と前月比はふつうの文字として書くので、そのまま読み上げに届きます。▲ と ▼ は記号の名前で読まれてしまうので読み上げから外し、「増」「減」の語を読み上げにだけ添えます。スパークラインは推移の形を見せる飾りとして、読み上げから外します。カードは透明度だけで出し、出る前から数字が届くようにします。動かすのは画面に入った 1 回だけで、動きを減らす設定では描き終えた状態で見せます。

つまみは、数える時間とカードのずらし、系列の色です。暗い地に一段明るい面のカードを置き、数字・線・バッジをどれも地より明るくすると、並んだ数字が際立ちます。管理画面のダッシュボードや、サービス紹介の実績の数字に向いています。

コード

HTML
<section class="kpi" aria-labelledby="kpi-title">
  <div class="kpi__head">
    <h2 class="kpi__title" id="kpi-title">月次レポート</h2>
    <p class="kpi__period">2026年9月・前月との比較</p>
  </div>

  <!-- 数字はそのまま文字として置く。data-series は、スパークラインに描く 12 か月分の値(線の形だけに使う) -->
  <ul class="kpi__list">
    <li class="kpi-card" data-series="42 48 45 53 51 58 62 60 68 74 71 84">
      <p class="kpi-card__label">売上</p>
      <p class="kpi-card__value"><span class="kpi-card__number">8,421</span><span class="kpi-card__unit">万円</span></p>
      <p class="kpi-card__delta">前月比<span class="kpi-card__badge"><span aria-hidden="true">▲</span>12.4%<span class="kpi__sr">増</span></span></p>
    </li>
    <li class="kpi-card" data-series="8.1 8.6 8.4 9.2 9.9 9.6 10.4 10.9 11.3 11.1 11.9 12.5">
      <p class="kpi-card__label">アクティブユーザー</p>
      <p class="kpi-card__value"><span class="kpi-card__number">12,480</span><span class="kpi-card__unit">人</span></p>
      <p class="kpi-card__delta">前月比<span class="kpi-card__badge"><span aria-hidden="true">▲</span>8.1%<span class="kpi__sr">増</span></span></p>
    </li>
    <li class="kpi-card" data-series="3.9 3.8 3.95 3.7 3.75 3.6 3.66 3.5 3.58 3.44 3.5 3.42">
      <p class="kpi-card__label">コンバージョン率</p>
      <p class="kpi-card__value"><span class="kpi-card__number">3.42</span><span class="kpi-card__unit">%</span></p>
      <p class="kpi-card__delta">前月比<span class="kpi-card__badge is-down"><span aria-hidden="true">▼</span>0.6%<span class="kpi__sr">減</span></span></p>
    </li>
    <li class="kpi-card" data-series="61 63 62 64 63.5 65 64.2 66 65.5 67.2 66.8 68.4">
      <p class="kpi-card__label">客単価</p>
      <p class="kpi-card__value"><span class="kpi-card__number">6,840</span><span class="kpi-card__unit">円</span></p>
      <p class="kpi-card__delta">前月比<span class="kpi-card__badge"><span aria-hidden="true">▲</span>4.3%<span class="kpi__sr">増</span></span></p>
    </li>
  </ul>
</section>

CSS
.kpi {
  --bg: #111111;
  --surface: #262626;                /* カードの面 */
  --ink: #eeece6;
  --mute: #898989;                   /* 期間の補足 */
  --mute-small: #a8a6a0;             /* 16px 未満の文字 */
  --up: #6ba37f;                     /* 上がったときのバッジ(小さい文字でも読める緑) */
  --down: #e07a62;                   /* 下がったときのバッジ */
  padding: 48px clamp(24px, 6vw, 96px);
  background: var(--bg);
  color: var(--ink);
}

.kpi__head { margin-bottom: 40px; }
.kpi__title { margin: 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.kpi__period { margin: 16px 0 0; color: var(--mute); font-size: 16px; letter-spacing: 0.04em; }

.kpi__list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 24px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.kpi-card { padding: 24px; border-radius: 4px; background: var(--surface); }

/* 系列の色(中身の色の Night の列の彩度を 1.25 倍にした色) */
.kpi-card:nth-child(1) { --color: #d9ba92; }
.kpi-card:nth-child(2) { --color: #93b2c9; }
.kpi-card:nth-child(3) { --color: #aea0cf; }
.kpi-card:nth-child(4) { --color: #a2c49f; }

/* 系列の色(--color)の点を、同じ色を薄く混ぜた丸で囲む */
.kpi-card__label { display: flex; align-items: center; gap: 12px; margin: 0; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; }
.kpi-card__label::before {
  content: "";
  flex: none;
  width: 10px;
  height: 10px;
  border: 11px solid color-mix(in srgb, var(--color) 20%, transparent);
  border-radius: 50%;
  background: var(--color);
  background-clip: content-box;
}

.kpi-card__value {
  margin: 24px 0 0;
  font-size: 44px;
  font-weight: 800;
  line-height: 1;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.kpi-card__unit { margin-left: 2px; font-size: 20px; font-weight: 700; letter-spacing: 0.04em; }

.kpi-card__delta { display: flex; align-items: center; gap: 8px; margin: 16px 0 0; color: var(--mute-small); font-size: 13px; letter-spacing: 0.04em; }

/* 前月比は、上がったら緑、下がったら赤の線の丸帯(地は塗らない) */
.kpi-card__badge {
  padding: 0 10px;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1px currentColor;
  color: var(--up);
  font-weight: 700;
  line-height: 24px;
}

.kpi-card__badge.is-down { color: var(--down); }

.kpi-card__spark { display: block; width: 100%; height: auto; margin-top: 32px; overflow: visible; }
.kpi-card__line { fill: none; stroke: var(--color); stroke-width: 3; stroke-linejoin: round; }
.kpi-card__dot { fill: var(--color); stroke: var(--surface); stroke-width: 2; }
.kpi-card__stop { stop-color: var(--color); stop-opacity: 0.24; }
.kpi-card__stop + .kpi-card__stop { stop-opacity: 0; }

/* 読み上げにだけ届く語(▲▼ の代わりの「増」「減」) */
.kpi__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

JavaScript
import { gsap } from "gsap";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";

gsap.registerPlugin(DrawSVGPlugin);

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const EACH = 0.12; // 隣のカードとのずれ(秒)
const COUNT = 1.3; // 数字を数え上げる時間
const DRAW = 1.1; // スパークラインを描く時間
const BOX = { width: 200, height: 72, pad: 6 }; // スパークラインの座標の大きさと余白
let uid = 0; // グラデーションの id を、ページの中で重ねない

function el(parent, name, attrs) {
  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);
  return node;
}

// 点を通るなめらかな線。前後の点から 3 次ベジェの制御点を決める(Catmull-Rom)
function curve(points) {
  return points.slice(1).map((p2, i) => {
    const p1 = points[i];
    const p0 = points[i - 1] ?? p1;
    const p3 = 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("");
}

// data-series から、線の下の面・線・終点の点を描く。推移の形を見せる飾りなので、読み上げからは外す
function sparkline(card) {
  const values = card.dataset.series.trim().split(/\s+/).map(Number);
  const min = Math.min(...values);
  const max = Math.max(...values);
  const { width, height, pad } = BOX;
  const points = values.map((v, i) => [pad + ((width - pad * 2) * i) / (values.length - 1), pad + (1 - (v - min) / (max - min)) * (height - pad * 2)]);
  const d = `M${points[0]}${curve(points)}`;
  const svg = el(card, "svg", { class: "kpi-card__spark", viewBox: `0 0 ${width} ${height}`, "aria-hidden": "true" });
  const id = `kpi-fade-${uid++}`;
  const fade = el(el(svg, "defs", {}), "linearGradient", { id, x2: 0, y2: 1 });
  el(fade, "stop", { offset: 0, class: "kpi-card__stop" });
  el(fade, "stop", { offset: 1, class: "kpi-card__stop" });
  return {
    area: el(svg, "path", { d: `${d}V${height}H${points[0][0]}Z`, fill: `url(#${id})` }),
    line: el(svg, "path", { class: "kpi-card__line", d }), // 線の端は平ら(丸いと長さ 0 でも点が残る)
    dot: el(svg, "circle", { class: "kpi-card__dot", cx: points.at(-1)[0], cy: points.at(-1)[1], r: 4.5 }),
  };
}

document.querySelectorAll(".kpi").forEach((section) => {
  const tl = gsap.timeline({ paused: true });
  section.querySelectorAll(".kpi-card").forEach((card, i) => {
    const number = card.querySelector(".kpi-card__number");
    const digits = (number.textContent.split(".")[1] ?? "").length; // 小数の桁は、書いてある値に合わせる
    const value = Number(number.textContent.replace(/,/g, ""));
    const state = { v: 0 };
    const { area, line, dot } = sparkline(card);
    const at = i * EACH;
    // 数えるあいだは、単位の手前の数字だけを書き換える
    const count = () => (number.textContent = state.v.toLocaleString("ja-JP", { minimumFractionDigits: digits, maximumFractionDigits: digits }));
    // カードは透明度だけで出す(visibility で隠すと、出る前の数字が読み上げに届かない)
    tl.from(card, { opacity: 0, y: 28, duration: 0.7, ease: "power3.out" }, at)
      .to(state, { v: value, duration: COUNT, ease: "power2.out", onUpdate: count }, at)
      .fromTo(line, { drawSVG: "0%" }, { drawSVG: "100%", duration: DRAW, ease: "power1.inOut" }, at + 0.25)
      .from(area, { opacity: 0, duration: 0.5, ease: "power1.out" }, at + DRAW)
      .from(dot, { attr: { r: 0 }, duration: 0.4, ease: "back.out(3)" }, at + DRAW + 0.2)
      .from(card.querySelector(".kpi-card__delta"), { opacity: 0, y: 6, duration: 0.4, ease: "power2.out" }, at + COUNT - 0.35);
  });

  // 動きを減らす設定では、描き終えた状態で見せる
  if (reduceMotion) {
    tl.progress(1);
    return;
  }
  // 画面に入ったら 1 回だけ動かす
  const io = new IntersectionObserver(([entry]) => {
    if (!entry.isIntersecting) return;
    io.disconnect();
    tl.play();
  }, { rootMargin: "0px 0px -20%" });
  io.observe(section);
});

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

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