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

グラフの数字が桁ごとに縦に回って変わる

デモ

仕様

用途
グラフ
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

桁ごとに0〜9の数字を縦に並べた帯を、窓の中でスクロールさせて数字を変えます。帯の位置を回った段数として持ち、目的の数字に着くまで下から上へ送るので、スロットのように数字が流れて止まります。

いちばん左の桁は1秒かけて回り、右の桁ほど0.15秒ずつ長く回るので、左から順に止まります。回り始めも右の桁ほど0.05秒ずつ遅らせ、止まる直前は行き過ぎてから戻るback.outのイージングで段の高さの16%だけ弾みます。桁ごとに周回する回数が違うので、弾む強さは回転量から逆算し、どの桁も同じだけ弾むようにしています。

表示する円の値はdata-valueに書き、桁の帯の数や最終的な数字はそこから作ります。同じ値をそのままaria-labelにも渡すので、桁が回っている途中の見た目とは別に、読み上げには最初から確定した金額だけが届きます。窓の上下は薄い霧のようにマスクして、数字が縁でふっと消えるようにしています。

回り始めるまでの間や、桁ごとの遅れの幅は、どれもスクリプトの先頭で変えられます。画面に入ったときに一度だけ回し、動きを減らす設定では最初から止まった数字で置きます。売上や利用者数、寄付額のような、注目を集めたい単一の数字の表示に向いています。

コード

HTML
<figure class="odometer">
  <figcaption class="odometer__caption">
    <span class="odometer__title">売上の合計</span>
    <span class="odometer__note">今月の累計・すべての販売経路</span>
  </figcaption>

  <!-- data-value が正の値(円)。桁の帯はこの文字数から JS が作る。見た目は飾りなので読み上げは aria-label に任せる -->
  <div class="odometer__display" role="img" data-value="1284560" aria-label="読み込み中">
    <span class="odometer__yen" aria-hidden="true">¥</span>
    <span class="odometer__digits" aria-hidden="true"></span>
  </div>
</figure>

CSS
.odometer {
  --ink: #262626;
  --mute: #6b6b6b;
  --surface: #ffffff;
  --tile: #f2f1ed;   /* 桁の札の地 */
  --line: rgba(38, 38, 38, 0.1);
  --h: 64px;   /* 1 段(1 文字)の高さ */
  --w: 46px;   /* 桁の札の幅 */
  margin: 0;
  padding: 32px;
  background: var(--surface);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.odometer__caption { display: grid; gap: 6px; margin-bottom: 28px; }
.odometer__title { font-size: 24px; font-weight: 700; letter-spacing: -0.01em; }
.odometer__note { font-size: 14px; letter-spacing: 0.04em; color: var(--mute); }

.odometer__display { display: flex; align-items: center; }

.odometer__yen {
  flex: none;
  width: 28px;
  height: var(--h);
  line-height: var(--h);
  font-size: 32px;
  font-weight: 700;
  color: var(--mute);
  text-align: center;
}

.odometer__digits { display: flex; }

/* 窓の上下を透かし、回っている数字が縁でふっと消えるようにする */
.odometer__tile {
  position: relative;
  width: var(--w);
  height: var(--h);
  margin-left: 4px;
  border-radius: 4px;
  background: var(--tile);
  box-shadow: inset 0 0 0 1px var(--line);
}

.odometer__window {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: inherit;
  -webkit-mask-image: linear-gradient(180deg, transparent, #000 22%, #000 78%, transparent);
  mask-image: linear-gradient(180deg, transparent, #000 22%, #000 78%, transparent);
}

/* inline の span には transform が効かない。数字を送るので block にする */
.odometer__strip {
  display: block;
  font-size: 40px;
  font-weight: 700;
  text-align: center;
}

.odometer__strip span { display: block; height: var(--h); line-height: var(--h); }

.odometer__comma {
  align-self: flex-end;
  width: 14px;
  height: var(--h);
  line-height: calc(var(--h) - 14px);
  font-size: 40px;
  font-weight: 700;
  color: var(--mute);
  text-align: center;
}

/* 狭い画面では桁の札を小さくして、横にはみ出さないようにする */
@media (max-width: 420px) {
  .odometer { padding: 24px; }
  .odometer__display { --h: 44px; --w: 30px; }
  .odometer__yen { font-size: 22px; width: 18px; }
  .odometer__strip,
  .odometer__comma { font-size: 28px; }
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPIN = 1; // いちばん左の桁が回る時間(秒)
const SPIN_EACH = 0.15; // 右へ 1 桁ごとに足す時間
const START_EACH = 0.05; // 右へ 1 桁ごとの、回り始めのずれ
const OVERSHOOT = 0.16; // 止まる前に行き過ぎる量(1 段の高さに対する割合)

// back.out(s) の行き過ぎは移動量の 4s³ / 27(s+1)² 倍。行き過ぎが 1 段の OVERSHOOT 倍になる s を求める
function backFor(travel) {
  const target = OVERSHOOT / travel;
  let lo = 0;
  let hi = 20;
  for (let k = 0; k < 40; k++) {
    const s = (lo + hi) / 2;
    if ((4 * s ** 3) / (27 * (s + 1) ** 2) < target) lo = s;
    else hi = s;
  }
  return gsap.parseEase(`back.out(${lo.toFixed(4)})`);
}

document.querySelectorAll(".odometer__display").forEach((display) => {
  // 値は data-value が正。桁の帯もこの文字数から作る
  const value = Number(display.dataset.value);
  display.setAttribute("aria-label", `${value.toLocaleString("ja-JP")}円`);

  const digitsEl = display.querySelector(".odometer__digits");
  const strips = [];
  const windows = [];
  for (const ch of value.toLocaleString("ja-JP")) {
    if (ch === ",") {
      digitsEl.insertAdjacentHTML("beforeend", `<span class="odometer__comma">,</span>`);
      continue;
    }
    const tile = document.createElement("span");
    tile.className = "odometer__tile";
    const win = document.createElement("span");
    win.className = "odometer__window";
    const strip = document.createElement("span");
    strip.className = "odometer__strip";
    // 0〜9 の下にもう 1 つ 0 を置いた 11 段。9 → 0 をまたいでも途切れない
    for (let k = 0; k <= 10; k++) {
      const d = document.createElement("span");
      d.textContent = String(k % 10);
      strip.append(d);
    }
    win.append(strip);
    tile.append(win);
    digitsEl.append(tile);
    strips.push(strip);
    windows.push(win);
  }

  // H は 1 段(窓)の高さ。帯そのものの高さは 11 段ぶんあるので、窓から測る
  const H = windows[0].offsetHeight;
  const digits = String(value).padStart(strips.length, "0").split("").map(Number);
  const states = strips.map(() => ({ v: 0 }));
  const draw = (i) => (strips[i].style.transform = `translateY(${-Math.round((states[i].v % 10) * H)}px)`);

  // 動きを減らす設定では、最初から止まった数字で見せる
  if (reduceMotion) {
    digits.forEach((d, i) => {
      states[i].v = d;
      draw(i);
    });
    return;
  }

  // 画面に入ったら 1 回だけ回す。右の桁ほど多く周回するので、左の桁から順に止まる
  const io = new IntersectionObserver(([entry]) => {
    if (!entry.isIntersecting) return;
    io.disconnect();
    digits.forEach((d, i) => {
      const travel = d + (i + 1) * 10;
      gsap.to(states[i], {
        v: travel,
        duration: SPIN + i * SPIN_EACH,
        delay: i * START_EACH,
        ease: backFor(travel),
        onUpdate: () => draw(i),
      });
    });
  }, { rootMargin: "0px 0px -20%" });
  io.observe(display);
});

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

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