デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
年のボタンを押すと、都市ごとの横棒が次の年の値へ伸び縮みし、棒の先の数字も数えながら変わります。行ごとに値と上下の位置を 1 つのオブジェクトに持ち、値は gsap.to で次の年へ動かします。動いているあいだは今の値で行を並べ替えて順位を出し、順位が変わった行だけを新しい段へ滑らせます。値が追い越した瞬間に入れ替わり始めるので、抜きつ抜かれつの様子がそのまま見えます。
値は 1.5 秒、段の入れ替わりは 0.5 秒で、どちらも power2.inOut です。入れ替わりを値の動きより短くしておくと、追い越したところで素早く並び直し、次の追い越しに間に合います。上の順位の行を手前に重ねるので、追い越す棒が上を通ります。ボタンのつまみは 0.45 秒で押した年へ滑ります。
行は表の順のまま並べておき、translateY で順位の段へずらします。DOM の並びを入れ替えると、動いている途中の位置が消えて棒が飛びます。段を入れ替えるあいだは 2 つの行が重なるので、名前の箱を地の色で塗り、手前の行の名前が奥の行の名前を隠すようにします。途中で別の年を押しても、overwrite を auto にしたトゥイーンが、今の値と位置から向かい直します。
値は表としてページに書き、スクリプトはその表を読んで棒を組みます。組んだあとの表は見えなくするだけにして、読み上げには残します。年のボタンは button 要素で、選んでいる年を aria-pressed で伝えるので、キーボードでも選べます。動きを減らす設定では、伸び縮みも並び直しもせず、押した年の並びにすぐ切り替えます。
つまみは、値が移る時間、入れ替わりの時間、目盛りの右端です。目盛りの右端をデータ全体の最大値に決めておくと、年が進むほど棒がそのまま伸びて見えます。年ごとの最大値に合わせて目盛りを変えると、先頭の棒がいつも端まで届く形になりますが、伸びは見えにくくなります。棒が伸び縮みするだけでなく順位の入れ替わりまで見せたい、ランキングやシェアの推移を年や月で見比べる節に向いています。
コード
<!-- data-max は目盛りの右端。値はこの表が正で、JS が表から横棒を組む(表は読み上げ用に残す) -->
<figure class="bar-race" data-max="2000">
<figcaption class="bar-race__caption">
<span class="bar-race__title">都市別の来訪者数</span>
<span class="bar-race__note">年間の合計・単位:万人</span>
</figcaption>
<!-- 年のボタン。押した年の値へ棒が伸び縮みし、値が追い越した棒は順位どおりに並び直す -->
<div class="bar-race__years" role="group" aria-label="表示する年">
<span class="bar-race__knob" aria-hidden="true"></span>
<button type="button" aria-pressed="true">2023</button>
<button type="button" aria-pressed="false">2024</button>
<button type="button" aria-pressed="false">2025</button>
<button type="button" aria-pressed="false">2026</button>
</div>
<!-- 行ごとの data-color が棒の色 -->
<table class="bar-race__table">
<thead>
<tr><th scope="col">都市</th><th scope="col">2023年</th><th scope="col">2024年</th><th scope="col">2025年</th><th scope="col">2026年</th></tr>
</thead>
<tbody>
<tr data-color="#d9ba92"><th scope="row">東京</th><td>1,320</td><td>1,480</td><td>1,610</td><td>1,760</td></tr>
<tr data-color="#748c9b"><th scope="row">大阪</th><td>980</td><td>1,060</td><td>1,150</td><td>1,260</td></tr>
<tr data-color="#93b2c9"><th scope="row">京都</th><td>860</td><td>1,010</td><td>1,240</td><td>1,390</td></tr>
<tr data-color="#e4d4bc"><th scope="row">福岡</th><td>470</td><td>690</td><td>980</td><td>1,320</td></tr>
<tr data-color="#60735f"><th scope="row">札幌</th><td>540</td><td>620</td><td>700</td><td>790</td></tr>
<tr data-color="#968bb2"><th scope="row">那覇</th><td>380</td><td>520</td><td>760</td><td>1,040</td></tr>
</tbody>
</table>
</figure>
.bar-race {
--bg: #111111;
--ink: #eeece6;
--mute: #898989;
--mute-small: #a8a6a0; /* 16px 未満の文字 */
--line: rgb(238 236 230 / 0.14);
--row: 40px; /* 棒の太さ */
--gap: 16px; /* 段のすき間 */
/* 見えなくした表が、狭い画面で横にはみ出さないよう切る */
position: relative;
overflow: clip;
margin: 0;
padding: 40px clamp(16px, 5vw, 64px);
background: var(--bg);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.bar-race__caption { display: grid; gap: 8px; margin-bottom: 24px; }
.bar-race__title { font-size: clamp(28px, 4vw, 44px); font-weight: 800; letter-spacing: -0.02em; }
.bar-race__note { font-size: 16px; letter-spacing: 0.04em; color: var(--mute); }
/* 年のボタン: 罫線の枠の中を、明るいつまみが押した年へ滑る */
.bar-race__years {
position: relative;
display: inline-flex;
padding: 4px;
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--line);
}
.bar-race__knob {
position: absolute;
top: 4px;
bottom: 4px;
left: 0;
border-radius: 999px;
background: var(--ink);
}
.bar-race__years button {
position: relative;
height: 40px;
padding: 0 clamp(12px, 2.5vw, 20px);
border: 0;
border-radius: 999px;
background: none;
color: var(--mute-small);
font: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
transition: color 0.3s;
}
.bar-race__years button[aria-pressed="true"] { color: var(--bg); }
.bar-race__years button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
/* 行は表の順に並べ、JS が translateY で順位の段へずらす */
.bar-race__bars { display: grid; gap: var(--gap); margin: 32px 0 0; padding: 0; list-style: none; }
.bar-race__bars li {
position: relative;
display: grid;
grid-template-columns: clamp(72px, 16vw, 152px) 1fr;
gap: 16px;
align-items: center;
height: var(--row);
}
/* 名前の箱は地の色で塗る。段を入れ替えるあいだ、手前の行の名前が奥の行の名前を隠す */
.bar-race__name { background: var(--bg); font-size: 15px; font-weight: 600; line-height: var(--row); letter-spacing: 0.04em; text-align: right; }
/* 右に数字の入る余白を取り、棒の幅はその残りに対する割合にする */
.bar-race__track { display: flex; align-items: center; height: 100%; padding-right: 64px; }
.bar-race__bar { flex: none; height: 100%; border-radius: 0 4px 4px 0; background: var(--color); }
.bar-race__value { margin-left: 12px; font-size: 15px; font-weight: 700; white-space: nowrap; }
/* 組んだあとの表は、読み上げ用に見えなくするだけ(display: none にすると、読み上げからも消える) */
.bar-race__table { margin-top: 24px; border-collapse: collapse; font-size: 14px; }
.bar-race__table th,
.bar-race__table td { padding: 8px 16px 8px 0; border-bottom: 1px solid var(--line); text-align: right; }
.bar-race__table th[scope="row"] { text-align: left; }
.bar-race.is-ready .bar-race__table { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.bar-race__years button { transition: none; }
}
import { gsap } from "gsap";
const RACE = 1.5; // 値が次の年へ移る時間(秒)
const SWAP = 0.5; // 追い越した棒が段を入れ替える時間(秒)
const KNOB = 0.45; // ボタンのつまみが滑る時間(秒)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const time = (seconds) => (reduceMotion ? 0 : seconds); // 動きを減らす設定では、その場で切り替える
document.querySelectorAll(".bar-race").forEach((chart) => {
// 都市と年ごとの値は表から読む。値を持つ場所が 1 つなので、表を直せば棒も直る
const rows = [...chart.querySelectorAll("tbody tr")];
const data = rows.map((row) => [...row.cells].slice(1).map((cell) => Number(cell.textContent.replace(/,/g, ""))));
const buttons = [...chart.querySelectorAll(".bar-race__years button")];
const knob = chart.querySelector(".bar-race__knob");
const max = Number(chart.dataset.max);
// 棒の行を組む。表と同じ値を描くだけなので、読み上げからは外す
const list = document.createElement("ol");
list.className = "bar-race__bars";
list.setAttribute("aria-hidden", "true");
const items = rows.map((row) => {
const li = list.appendChild(document.createElement("li"));
li.style.setProperty("--color", row.dataset.color);
li.innerHTML = '<span class="bar-race__name"></span><span class="bar-race__track"><span class="bar-race__bar"></span><span class="bar-race__value"></span></span>';
li.firstChild.textContent = row.cells[0].textContent;
return { li, bar: li.querySelector(".bar-race__bar"), value: li.querySelector(".bar-race__value") };
});
chart.querySelector(".bar-race__years").after(list);
chart.classList.add("is-ready");
const pitch = items[1].li.offsetTop - items[0].li.offsetTop; // 段の間隔(行の高さ+すき間)
// 行ごとの値 v と、表の順の位置からのずれ y。変わるたびに render で描く
const state = data.map((values) => ({ v: values[0], y: 0 }));
let order = null;
const render = () => {
const next = state.map((_, i) => i).sort((a, b) => state[b].v - state[a].v);
const prev = order;
order = next;
next.forEach((row, rank) => {
const y = (rank - row) * pitch;
// 今の値で並べた順位が変わった行だけ、新しい段へ動かす(今の位置から向かい直す)。最初と、動きを減らす設定ではその場に置く
if (!prev || reduceMotion) state[row].y = y;
else if (prev[rank] !== row) gsap.to(state[row], { y, duration: SWAP, ease: "power2.inOut", overwrite: "auto", onUpdate: render });
});
state.forEach(({ v, y }, i) => {
// 上の順位の行を手前に。追い越す棒が上を通る
gsap.set(items[i].li, { y, zIndex: rows.length - next.indexOf(i) });
items[i].bar.style.width = `${(v / max) * 100}%`;
items[i].value.textContent = Math.round(v).toLocaleString("ja-JP");
});
};
const press = (year) => {
buttons.forEach((button, k) => button.setAttribute("aria-pressed", String(k === year)));
const { offsetLeft: x, offsetWidth: width } = buttons[year];
gsap.to(knob, { x, width, duration: time(KNOB), ease: "power3.inOut", overwrite: "auto" });
gsap.to(state, { v: (i) => data[i][year], duration: time(RACE), ease: "power2.inOut", overwrite: "auto", onUpdate: render });
};
buttons.forEach((button, year) => button.addEventListener("click", () => press(year)));
gsap.set(knob, { x: buttons[0].offsetLeft, width: buttons[0].offsetWidth });
render();
});