デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- 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 回だけで、動きを減らす設定では描き終えた状態で見せます。
つまみは、数える時間とカードのずらし、系列の色です。暗い地に一段明るい面のカードを置き、数字・線・バッジをどれも地より明るくすると、並んだ数字が際立ちます。管理画面のダッシュボードや、サービス紹介の実績の数字に向いています。
コード
<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>
.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; }
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);
});