デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
期間のタブ(週別・月別・年別)を押すと、表示中の 8 本の棒が左から順に次のデータへ伸び縮みし、棒の上の数字と売上の合計も数えながら変わります。目盛り・横軸のラベル・前期比は、いったん上へ抜けてから文言を替え、下から入ります。棒ごとに高さと数字の 2 つの値を持ち、gsap.to で次のデータへ動かして、変わるたびに描き直します。
棒は 0.9 秒の power3.inOut で、隣の棒と 0.05 秒ずつずらして動かします。ずらしを短くすると左から波が走るように見え、長くすると 1 本ずつ順に動いて切り替えが間延びします。合計は棒が動き終わるまでの 1.25 秒で数え、ラベルは 0.5 秒の前半で上へ抜け、後半で下から入ります。前後の文言を重ねないので、途中のどの瞬間でも字が重なって読めなくなることがありません。
目盛りの上端も 200 → 1,000 → 12,000 と期間ごとに変わるので、実際の値をそのまま高さにして補間すると、途中で伸び縮みの向きが逆になる棒が出ます。高さは目盛りの上端に対する割合、数字は実際の値として、別々に動かします。続けて押したときは、ずらしのあるトゥイーンの後始末を overwrite の auto に任せないでください。待っている右側の棒の古いトゥイーンが後から始まり、新しい値を上書きします。前のトゥイーンを覚えておき、押すたびに止めてから作ります。
値は期間ごとの表としてページに書き、スクリプトはその表を読んで目盛りと棒を組みます。組んだあとの表は見えなくするだけにして、選んでいる期間の表だけを読み上げに残します。タブは button 要素で、選んでいる期間を aria-pressed で伝え、切り替えたことを見えない status に書きます。動きを減らす設定では、伸び縮みも入れ替えもせず、その場で次の期間の表示に替えます。狭い画面では、横軸のラベルを右端から 1 つおきに出します。
つまみは、伸び縮みの時間、棒どうしのずれ、ラベルが抜ける距離(8px)、棒の色です。いまの期間(右端)の棒だけ明るい色にすると、最新の値がどこにあるか一目でわかります。色相ではなく明るさで差を付けておくと、色の見え方が違っても見分けられます。最初に出すときは、画面に入ったところで 0 から伸ばす入り方と組み合わせられます。ダッシュボードの期間の切り替えや、同じ指標を週・月・年で見比べる節に向いています。
コード
<figure class="bar-switch">
<figcaption class="bar-switch__head">
<span class="bar-switch__label">売上の合計</span>
<span class="bar-switch__summary">
<!-- data-large は 1 億円からの単位 -->
<span class="bar-switch__total"><b>1,060</b><span data-large="億円">万円</span></span>
<span class="bar-switch__delta"><span>▲8.6%</span></span>
<span class="bar-switch__compare">前の8週間との比較</span>
</span>
</figcaption>
<!-- 期間のボタン。押すと、同じ順の表の値へ棒が伸び縮みする -->
<div class="bar-switch__tabs" role="group" aria-label="表示する期間">
<span class="bar-switch__knob" aria-hidden="true"></span>
<button type="button" aria-pressed="true">週別</button>
<button type="button" aria-pressed="false">月別</button>
<button type="button" aria-pressed="false">年別</button>
</div>
<p class="bar-switch__legend">
<span>単位:万円</span><span class="is-past">過去の期間</span><span class="is-now">最新の期間</span>
</p>
<!-- JS が表から目盛りと棒を組む。表と同じ値を描くだけなので、読み上げからは外す -->
<div class="bar-switch__plot" aria-hidden="true"></div>
<!-- 値はこの表が正。data-max は目盛りの上端、data-delta と data-compare は前期比とその説明 -->
<table data-max="200" data-delta="▲8.6%" data-compare="前の8週間との比較">
<caption>週別の売上(単位:万円)</caption>
<tr><th scope="col">7月27日</th><th scope="col">8月3日</th><th scope="col">8月10日</th><th scope="col">8月17日</th><th scope="col">8月24日</th><th scope="col">8月31日</th><th scope="col">9月7日</th><th scope="col">9月14日</th></tr>
<tr><td>92</td><td>118</td><td>106</td><td>139</td><td>127</td><td>158</td><td>144</td><td>176</td></tr>
</table>
<table data-max="1000" data-delta="▲14.2%" data-compare="前の8か月との比較">
<caption>月別の売上(単位:万円)</caption>
<tr><th scope="col">2月</th><th scope="col">3月</th><th scope="col">4月</th><th scope="col">5月</th><th scope="col">6月</th><th scope="col">7月</th><th scope="col">8月</th><th scope="col">9月</th></tr>
<tr><td>420</td><td>480</td><td>450</td><td>580</td><td>540</td><td>660</td><td>710</td><td>840</td></tr>
</table>
<table data-max="12000" data-delta="▲30.2%" data-compare="前の8年間との比較">
<caption>年別の売上(単位:万円)</caption>
<tr><th scope="col">2019年</th><th scope="col">2020年</th><th scope="col">2021年</th><th scope="col">2022年</th><th scope="col">2023年</th><th scope="col">2024年</th><th scope="col">2025年</th><th scope="col">2026年</th></tr>
<tr><td>1,600</td><td>2,400</td><td>3,100</td><td>3,900</td><td>5,200</td><td>6,400</td><td>8,100</td><td>10,400</td></tr>
</table>
<p class="bar-switch__status" role="status"></p>
</figure>
.bar-switch {
--bg: #111111;
--surface: #262626;
--ink: #eeece6;
--mute-small: #a8a6a0; /* 16px 未満の文字 */
--line: rgb(238 236 230 / 0.14);
--up: #4f8a64; /* 前期比の枠(成功の色) */
--up-text: #6ba37f; /* 前期比の文字(小さい文字用の成功の色) */
--bar-top: #93b2c9;
--bar-bottom: #748c9b;
--now-top: #e4d4bc; /* いまの期間(右端)の棒。明るさの差で見分ける */
--now-bottom: #d9ba92;
/* 見えなくした表が、狭い画面で横にはみ出さないよう切る */
position: relative;
overflow: clip;
display: grid;
grid-template-columns: 1fr auto;
gap: 16px 24px;
margin: 0;
padding: 48px clamp(16px, 5vw, 56px) 56px;
border-radius: 4px;
background: var(--surface);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.bar-switch__head { grid-row: span 2; display: grid; gap: 8px; align-content: start; }
.bar-switch__label { font-size: 14px; letter-spacing: 0.04em; color: var(--mute-small); }
.bar-switch__summary { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; }
/* 幅を決めておき、数えているあいだも右の札を動かさない */
.bar-switch__total { min-width: 184px; font-size: 44px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; white-space: nowrap; }
.bar-switch__total span { margin-left: 2px; font-size: 20px; font-weight: 700; letter-spacing: 0.04em; }
.bar-switch__delta { display: inline-flex; justify-content: center; align-items: center; width: 72px; height: 24px; overflow: hidden; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--up); color: var(--up-text); font-size: 13px; font-weight: 700; }
/* 入れ替わる文言は transform で上下するので、inline にしない */
.bar-switch__delta span,
.bar-switch__compare { display: inline-block; }
.bar-switch__compare { font-size: 14px; letter-spacing: 0.04em; color: var(--mute-small); white-space: nowrap; }
/* 期間のボタン: 罫線の枠の中を、明るいつまみが押した期間へ滑る */
.bar-switch__tabs { position: relative; justify-self: end; display: flex; padding: 4px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--line); }
.bar-switch__knob { position: absolute; top: 4px; bottom: 4px; left: 0; border-radius: 999px; background: var(--ink); }
.bar-switch__tabs button {
position: relative; height: 40px; padding: 0 20px; border: 0; border-radius: 999px; background: none; cursor: pointer;
color: var(--mute-small); font: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.04em;
}
.bar-switch__tabs [aria-pressed="true"] { color: var(--bg); }
.bar-switch__tabs button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.bar-switch__legend { justify-self: end; display: flex; flex-wrap: wrap; gap: 8px 24px; margin: 0; font-size: 13px; letter-spacing: 0.04em; color: var(--mute-small); }
.bar-switch__legend [class]::before { content: ""; display: inline-block; width: 10px; height: 10px; margin-right: 8px; border-radius: 2px; background: var(--bar-top); }
.bar-switch__legend .is-now::before { background: var(--now-top); }
/* 目盛りの線は動かさず、ラベルだけ入れ替える。棒の高さは --h(目盛りの上端に対する割合) */
.bar-switch__plot { grid-column: 1 / -1; position: relative; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); height: 312px; margin: 56px 0 40px 72px; }
.bar-switch__tick { position: absolute; left: 0; right: 0; bottom: calc(var(--at) * 100%); height: 1px; background: var(--line); }
.bar-switch__tick:first-child { height: 2px; }
.bar-switch__tick span { position: absolute; right: calc(100% + 16px); top: -8px; font-size: 13px; line-height: 16px; color: var(--mute-small); }
.bar-switch__col { position: relative; }
.bar-switch__bar { position: absolute; left: 0; right: 0; bottom: 0; width: min(56px, 62%); height: calc(var(--h, 0) * 100%); margin: 0 auto; border-radius: 4px 4px 0 0; background: linear-gradient(var(--bar-top), var(--bar-bottom)); }
.is-now .bar-switch__bar { background: linear-gradient(var(--now-top), var(--now-bottom)); }
/* 数字は棒の先端の 12px 上。flex で中央にそろえると、列より広くても左右均等にはみ出す */
.bar-switch__value,
.bar-switch__axis { position: absolute; left: 0; right: 0; display: flex; justify-content: center; line-height: 1; white-space: nowrap; }
.bar-switch__value { bottom: calc(var(--h, 0) * 100% + 12px); font-size: 15px; font-weight: 700; }
.is-now .bar-switch__value { color: var(--now-top); }
.bar-switch__axis { top: calc(100% + 18px); font-size: 14px; letter-spacing: 0.04em; color: var(--mute-small); }
/* 組んだあとの表は、読み上げ用に見えなくするだけ(display: none にすると、読み上げからも消える) */
.bar-switch table { grid-column: 1 / -1; font-size: 14px; }
.bar-switch.is-ready table,
.bar-switch__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* 狭い画面では、ボタンを見出しの下へ回し、グラフの文字を小さくする。横軸のラベルは、右端(最新)から 1 つおきに出す */
@media (max-width: 600px) {
.bar-switch { grid-template-columns: 1fr; }
.bar-switch__head { grid-row: auto; }
.bar-switch__tabs,
.bar-switch__legend { justify-self: start; }
.bar-switch__total { font-size: 36px; }
.bar-switch__plot { height: 240px; margin-left: 40px; }
.bar-switch__tick span { right: calc(100% + 8px); font-size: 11px; }
.bar-switch__value,
.bar-switch__axis { font-size: 11px; letter-spacing: 0; }
.bar-switch__col:nth-last-child(even of .bar-switch__col) .bar-switch__axis { visibility: hidden; }
}
/* 文字の色は、つまみが滑る間に替える。動きを減らす設定では、その場で替える */
@media (prefers-reduced-motion: no-preference) {
.bar-switch__tabs button { transition: color 0.25s; }
}
import { gsap } from "gsap";
const TICKS = 4; // 0 より上の目盛りの本数
const LEAD = 0.12; // 押してから棒とラベルが動き始めるまで(秒)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const time = (seconds) => (reduceMotion ? 0 : seconds); // 動きを減らす設定では、その場で切り替える
const number = (v) => Math.round(v).toLocaleString("ja-JP");
const sum = (values) => values.reduce((a, b) => a + b, 0);
document.querySelectorAll(".bar-switch").forEach((chart) => {
// 期間ごとの値・目盛りの上端・前期比・横軸は表から読む。値を持つ場所が 1 つなので、表を直せば棒も直る
const ranges = [...chart.querySelectorAll("table")].map((table) => {
const [labels, values] = [...table.rows].map((row) => [...row.cells].map((cell) => cell.textContent));
return { table, ...table.dataset, max: Number(table.dataset.max), labels, values: values.map((v) => Number(v.replace(/,/g, ""))) };
});
const buttons = [...chart.querySelectorAll(".bar-switch__tabs button")];
const knob = chart.querySelector(".bar-switch__knob");
const plot = chart.querySelector(".bar-switch__plot");
const [digits, unit] = chart.querySelector(".bar-switch__total").children;
const units = [unit.textContent, unit.dataset.large];
const delta = chart.querySelector(".bar-switch__delta span");
const compare = chart.querySelector(".bar-switch__compare");
const status = chart.querySelector(".bar-switch__status");
let current = buttons.findIndex((button) => button.getAttribute("aria-pressed") === "true");
const start = ranges[current];
// 目盛り(下から)と、棒・数字・横軸のラベルの列を組む
for (let j = 0; j <= TICKS; j++) plot.insertAdjacentHTML("beforeend", `<span class="bar-switch__tick" style="--at: ${j / TICKS}"><span></span></span>`);
start.labels.forEach(() => plot.insertAdjacentHTML("beforeend", '<div class="bar-switch__col"><span class="bar-switch__bar"></span><span class="bar-switch__value"></span><span class="bar-switch__axis"></span></div>'));
const ticks = [...plot.querySelectorAll(".bar-switch__tick span")];
const cols = [...plot.querySelectorAll(".bar-switch__col")].map((col) => ({ col, value: col.children[1], axis: col.children[2] }));
ticks.forEach((label, j) => (label.textContent = number((start.max * j) / TICKS)));
cols.forEach(({ axis }, i) => (axis.textContent = start.labels[i]));
cols.at(-1).col.classList.add("is-now"); // いまの期間(右端)だけ明るい色
// 棒ごとの h(目盛りの上端に対する割合)と n(数字)。目盛りも変わるので、実際の値をそのまま高さにしない
const state = start.values.map((v) => ({ h: v / start.max, n: v }));
const total = { v: sum(start.values) };
const draw = () => {
state.forEach((s, i) => {
cols[i].col.style.setProperty("--h", s.h);
cols[i].value.textContent = number(s.n);
});
const large = total.v >= 9999.5; // 1 億円からは億円にして小数 2 桁
digits.textContent = large ? (total.v / 10000).toFixed(2) : number(total.v);
unit.textContent = units[large ? 1 : 0];
};
// 動かしている相手 → トゥイーン。ずらしのあるものは overwrite に任せず、前のものを止める(待っている古い分が後から上書きする)
const running = new Map();
const run = (key, tween) => {
running.get(key)?.kill();
running.set(key, tween);
};
// ラベルは、上へ抜けてから文言を替え、下から入る(2 つの文言を重ねない)。同じ文言なら、今の位置から戻すだけ
const swap = (el, text, delay) => {
running.get(el)?.kill();
if (reduceMotion) return (el.textContent = text);
const tl = gsap.timeline({ delay });
if (el.textContent !== text) {
tl.to(el, { y: -8, opacity: 0, duration: 0.25, ease: "power2.in" })
.call(() => (el.textContent = text))
.set(el, { y: 8 });
}
running.set(el, tl.to(el, { y: 0, opacity: 1, duration: 0.25, ease: "power2.out" }));
};
const placeKnob = (duration) => {
const { offsetLeft: x, offsetWidth: width } = buttons[current];
gsap.to(knob, { x, width, duration, ease: "power3.inOut", overwrite: "auto" });
};
const press = (next) => {
if (next === current) return;
current = next;
const r = ranges[next];
buttons.forEach((button, i) => button.setAttribute("aria-pressed", String(i === next)));
ranges.forEach((range, i) => (range.table.hidden = i !== next));
status.textContent = `${buttons[next].textContent}の表示に切り替えました`;
placeKnob(time(0.45));
run(state, gsap.to(state, { h: (i) => r.values[i] / r.max, n: (i) => r.values[i], duration: time(0.9), ease: "power3.inOut", stagger: time(0.05), delay: time(LEAD), onUpdate: draw }));
run(total, gsap.to(total, { v: sum(r.values), duration: time(1.25), ease: "power3.inOut", delay: time(LEAD), onUpdate: draw }));
ticks.forEach((label, j) => j && swap(label, number((r.max * j) / TICKS), LEAD + (j - 1) * 0.04));
cols.forEach(({ axis }, i) => swap(axis, r.labels[i], LEAD + i * 0.05)); // 横軸は棒と同じずれで
swap(delta, r.delta, LEAD);
swap(compare, r.compare, LEAD);
};
buttons.forEach((button, i) => button.addEventListener("click", () => press(i)));
ranges.forEach((range, i) => (range.table.hidden = i !== current));
chart.classList.add("is-ready");
draw();
// ボタンの幅は画面の幅で変わるので、大きさが変わるたびにつまみを合わせ直す
new ResizeObserver(() => placeKnob(0)).observe(knob.parentElement);
});