デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
3 つの層が、下の層から順に、層の中の水位が上がるように満ちていきます。面の上端と下端の y を水位ではさんで描くので、マスクの要素を置かなくても、水位より下だけが見えます。少し遅れて層の上端の折れ線が左から右へ引かれ、面より明るい線が境目をはっきりさせます。層の色は、下から濃い・中間・淡いの順に明るさを離しているので、色の違いが分かりにくい人にも積み重なりが読めます。
1 つの層が満ちるのは 0.85 秒で、次の層は 0.55 秒遅れて始めます。遅れを詰めると 3 層が一度に立ち上がって見え、広げると最後の層が出るまで間延びします。折れ線は、面が満ち始めて 0.45 秒後から 0.8 秒かけて引き、面の縁を追い越さない速さにしています。
線を左から描く stroke-dasharray は、CSS ではなく要素の style に書いてください。CSS は SVG の属性より強いので、スクリプトが属性を書き換えても効かなくなります。線に pathLength を 1 と書いておくと、線の長さを測らずに、送りの量を 1 から 0 へ動かすだけで引けます。
凡例を押すと、その層の重みが 0 になり、上の層が下りて積み直り、右上の合計も減ります。積み上げは毎フレーム重みから計算し直すので、面も上端の折れ線も同じ動きでつながります。値は表としてページに書き、スクリプトはその表を読んで面を組みます。組んだあとの表は見えなくするだけにして読み上げに残し、押した状態は aria-pressed で伝えます。
つまみは、満ちる時間・層どうしのずれ・面の濃さ(70%)です。目盛りの上端は層を消しても動かさないので、残りの高さの読み方が変わりません。層は 3〜4 つまでにし、細い層が増えるときは「その他」にまとめます。全体の伸びと内訳を同時に見せたい節、たとえばデバイス別のセッション数やチャネル別の売上に向いています。
コード
<figure class="area" data-max="2400">
<figcaption class="area__caption">
<span class="area__eyebrow">Traffic mix</span>
<span class="area__title"><span>デバイス別の</span><span>セッション数</span></span>
<span class="area__note"><span>2026年4月〜10月・</span><span>デバイス別・単位:千セッション</span></span>
</figcaption>
<p class="area__total">表示中の合計<b>13,140</b>千セッション</p>
<!-- 目盛りと月は HTML に置き、面と上端の折れ線だけを JS が描く -->
<div class="area__plot">
<div class="area__grid">
<svg class="area__svg" aria-hidden="true"></svg>
<ul class="area__y" aria-hidden="true">
<li style="--v: 1">2,400</li>
<li style="--v: 0.75">1,800</li>
<li style="--v: 0.5">1,200</li>
<li style="--v: 0.25">600</li>
<li style="--v: 0">0</li>
</ul>
<ul class="area__x" aria-hidden="true">
<li style="--i: 0">4月</li>
<li style="--i: 1">5月</li>
<li style="--i: 2">6月</li>
<li style="--i: 3">7月</li>
<li style="--i: 4">8月</li>
<li style="--i: 5">9月</li>
<li style="--i: 6">10月</li>
</ul>
</div>
</div>
<!-- 凡例は押して層を出し入れするので button にする(右の数はまとめて読まれないよう隠す) -->
<ul class="area__legend">
<li><button type="button" aria-pressed="true">モバイル<b aria-hidden="true">7,280</b></button></li>
<li><button type="button" aria-pressed="true">デスクトップ<b aria-hidden="true">4,340</b></button></li>
<li><button type="button" aria-pressed="true">タブレット<b aria-hidden="true">1,520</b></button></li>
</ul>
<p class="area__status" role="status"></p>
<!-- 値はこの表が正。面の色(--color)も、ここから読む -->
<table class="area__table">
<caption>デバイス別のセッション数(千セッション)</caption>
<thead>
<tr>
<th scope="col">月</th>
<th scope="col" style="--color: #5c91b3">モバイル</th>
<th scope="col" style="--color: #93d68d">デスクトップ</th>
<th scope="col" style="--color: #f1dcb8">タブレット</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">4月</th><td>820</td><td>540</td><td>180</td></tr>
<tr><th scope="row">5月</th><td>900</td><td>560</td><td>200</td></tr>
<tr><th scope="row">6月</th><td>960</td><td>600</td><td>190</td></tr>
<tr><th scope="row">7月</th><td>1,040</td><td>580</td><td>220</td></tr>
<tr><th scope="row">8月</th><td>1,180</td><td>640</td><td>240</td></tr>
<tr><th scope="row">9月</th><td>1,120</td><td>700</td><td>230</td></tr>
<tr><th scope="row">10月</th><td>1,260</td><td>720</td><td>260</td></tr>
</tbody>
</table>
</figure>
.area {
--bg: #111111;
--ink: #eeece6;
--mute: #898989;
--mute-small: #a8a6a0; /* 16px 未満の文字 */
--line: rgb(238 236 230 / 0.14);
--height: 300px; /* グラフの高さ */
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;
}
.area__caption { display: grid; gap: 10px; }
.area__eyebrow { color: var(--mute-small); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.area__title { font-size: clamp(28px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.area__note { color: var(--mute); font-size: 16px; letter-spacing: 0.04em; }
.area__title span,
.area__note span { display: inline-block; } /* 狭い画面でも、語の途中で折り返さない */
.area__total { margin: 0; color: var(--mute); font-size: 14px; letter-spacing: 0.04em; }
.area__total b { margin-inline: 10px 2px; color: var(--ink); font-size: clamp(28px, 6vw, 36px); font-weight: 800; letter-spacing: -0.02em; }
/* 左に目盛り、下に月を置く余白 */
.area__plot { padding: 0 0 30px 52px; }
.area__grid {
position: relative;
height: var(--height);
/* 目盛りの線。下端から 25% ごとに 1px(上端の線だけは影で引く) */
background-image: repeating-linear-gradient(to top, var(--line) 0 1px, transparent 1px 25%);
box-shadow: inset 0 1px 0 var(--line);
}
.area__svg { display: block; width: 100%; height: 100%; }
.area__y,
.area__x { margin: 0; padding: 0; color: var(--mute-small); font-size: 13px; list-style: none; }
.area__y li { position: absolute; right: calc(100% + 10px); bottom: calc(var(--v) * 100%); transform: translateY(50%); }
.area__x li { position: absolute; top: 100%; left: calc(var(--i) / 6 * 100%); transform: translate(-50%, 10px); white-space: nowrap; }
/* 両端の月は、枠の外へはみ出さないよう内側にそろえる */
.area__x li:first-child { transform: translate(0, 10px); }
.area__x li:last-child { transform: translate(-100%, 10px); }
.area__legend { display: flex; flex-wrap: wrap; gap: 4px 20px; margin: 0; padding: 0; list-style: none; }
.area__legend button {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 0;
border-radius: 4px;
background: none;
color: var(--ink);
font-family: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
}
.area__legend button::before {
content: "";
width: 14px;
height: 14px;
border: 1.5px solid var(--color);
border-radius: 3px;
background: var(--color);
transition: background-color 0.3s;
}
/* 消した層は、色の四角の塗りを抜いて文字を淡くする */
.area__legend button[aria-pressed="false"] { color: var(--mute); }
.area__legend button[aria-pressed="false"]::before { background: none; }
.area__legend button b { color: var(--mute-small); font-size: 14px; font-weight: 400; }
.area__legend button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
/* 読み上げだけに届ける。表は、スクリプトが面を描くまで見えたまま残す */
.area__status,
.area.is-live .area__table { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.area__table { width: 100%; border-spacing: 0; }
.area__table caption { padding-bottom: 12px; color: var(--mute-small); font-size: 13px; text-align: left; }
.area__table th,
.area__table td { padding: 10px 12px; border-top: 1px solid var(--line); font-size: 14px; text-align: right; }
.area__table th:first-child { text-align: left; }
.area__table thead th { border: 0; color: var(--mute-small); font-size: 13px; font-weight: 600; }
@media (prefers-reduced-motion: reduce) {
.area__legend button::before { transition: none; }
}
import { gsap } from "gsap";
// 動きを減らす設定では、満ちも積み直しも一瞬で終わらせる
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const time = (seconds) => (reduceMotion ? 0 : seconds);
const node = (tag) => document.createElementNS("http://www.w3.org/2000/svg", tag);
const num = (v) => Math.round(v).toLocaleString("ja-JP");
document.querySelectorAll(".area").forEach((chart) => {
const svg = chart.querySelector(".area__svg");
const grid = chart.querySelector(".area__grid");
const total = chart.querySelector(".area__total b");
const status = chart.querySelector(".area__status");
const max = Number(chart.dataset.max);
const rows = [...chart.querySelectorAll("tbody tr")];
// 値も色も表が正。列ごとに層を作る(下の層から)
const layers = [...chart.querySelectorAll("thead th")].slice(1).map((th, i) => ({
name: th.textContent,
color: getComputedStyle(th).getPropertyValue("--color"),
values: rows.map((row) => Number(row.cells[i + 1].textContent.replace(/[^\d.]/g, ""))),
p: 0, // 満ちた割合
ln: 0, // 上端の折れ線を引いた割合
w: 1, // 表示の重み(凡例で 0 / 1)
}));
for (const layer of layers) {
layer.area = svg.appendChild(node("path"));
layer.area.style.fill = layer.color;
}
for (const layer of layers) {
layer.line = svg.appendChild(node("path")); // 折れ線は、どの面より上に描く
layer.line.setAttribute("pathLength", "1"); // 長さを 1 として扱うと、送りの量が割合で書ける
// 破線は style に書く。CSS に書くと SVG の属性に勝ち、スクリプトが書き換えても効かなくなる
layer.line.style.cssText = `fill:none;stroke:${layer.color};stroke-width:2.5;stroke-linejoin:round;stroke-dasharray:1 1`;
}
let w = 0;
let h = 0;
const draw = () => {
const xs = rows.map((_, i) => ((i / (rows.length - 1)) * w).toFixed(1));
const lower = rows.map(() => h);
let sum = 0;
for (const layer of layers) {
const upper = rows.map((_, m) => lower[m] - ((layer.values[m] * layer.w) / max) * h);
// 水位。この線より下だけを見せるので、上下の境目を水位ではさむ(満ちきると全部が出る)
const base = Math.max(...lower);
const water = base - (base - Math.min(...upper) + 4) * layer.p;
const edge = (list) => list.map((y, m) => `${xs[m]},${Math.max(y, water).toFixed(1)}`);
layer.area.setAttribute("d", `M${edge(upper).join("L")}L${edge(lower).reverse().join("L")}Z`);
layer.area.style.fillOpacity = 0.7 * layer.w;
layer.line.setAttribute("d", `M${upper.map((y, m) => `${xs[m]},${y.toFixed(1)}`).join("L")}`);
layer.line.style.strokeDashoffset = 1 - layer.ln;
layer.line.style.strokeOpacity = layer.w;
sum += layer.values.reduce((all, v) => all + v, 0) * layer.w * layer.p;
upper.forEach((y, m) => (lower[m] = y));
}
total.textContent = num(sum);
};
// 枠の px をそのまま座標にする(縦横の比が変わっても、折れ線の太さが崩れない)
new ResizeObserver(() => {
({ width: w, height: h } = grid.getBoundingClientRect());
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
draw();
}).observe(grid);
chart.classList.add("is-live"); // ここから先は、表は読み上げにだけ残す
// 画面に入ったら 1 回だけ満たす。動きを減らす設定では、満ちた絵で置く
const play = () => {
if (reduceMotion) {
for (const layer of layers) Object.assign(layer, { p: 1, ln: 1 });
draw();
return;
}
const tl = gsap.timeline({ onUpdate: draw });
layers.forEach((layer, i) => {
tl.to(layer, { p: 1, duration: 0.85, ease: "power2.out" }, i * 0.55);
tl.to(layer, { ln: 1, duration: 0.8, ease: "power1.inOut" }, i * 0.55 + 0.45);
});
};
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
io.unobserve(entry.target);
play();
}
}, { rootMargin: "0px 0px -20%" });
io.observe(chart);
// 凡例を押すと、その層の重みが 0 になり、残りの層が積み直る
chart.querySelectorAll(".area__legend button").forEach((button, i) => {
button.style.setProperty("--color", layers[i].color);
button.addEventListener("click", () => {
const on = button.getAttribute("aria-pressed") === "false";
button.setAttribute("aria-pressed", String(on));
gsap.to(layers[i], { w: on ? 1 : 0, duration: time(0.8), ease: "power2.inOut", overwrite: "auto", onUpdate: draw });
status.textContent = `${layers[i].name}を${on ? "表示" : "非表示"}にしました`;
});
});
});