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

横棒グラフの順位が、値の伸びに合わせて入れ替わる

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
グラフ
動作
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 で伝えるので、キーボードでも選べます。動きを減らす設定では、伸び縮みも並び直しもせず、押した年の並びにすぐ切り替えます。

つまみは、値が移る時間、入れ替わりの時間、目盛りの右端です。目盛りの右端をデータ全体の最大値に決めておくと、年が進むほど棒がそのまま伸びて見えます。年ごとの最大値に合わせて目盛りを変えると、先頭の棒がいつも端まで届く形になりますが、伸びは見えにくくなります。棒が伸び縮みするだけでなく順位の入れ替わりまで見せたい、ランキングやシェアの推移を年や月で見比べる節に向いています。

コード

HTML
<!-- 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>

CSS
.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; }
}

JavaScript
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();
});

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

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