デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
桁ごとに0〜9の数字を縦に並べた帯を、窓の中でスクロールさせて数字を変えます。帯の位置を回った段数として持ち、目的の数字に着くまで下から上へ送るので、スロットのように数字が流れて止まります。
いちばん左の桁は1秒かけて回り、右の桁ほど0.15秒ずつ長く回るので、左から順に止まります。回り始めも右の桁ほど0.05秒ずつ遅らせ、止まる直前は行き過ぎてから戻るback.outのイージングで段の高さの16%だけ弾みます。桁ごとに周回する回数が違うので、弾む強さは回転量から逆算し、どの桁も同じだけ弾むようにしています。
表示する円の値はdata-valueに書き、桁の帯の数や最終的な数字はそこから作ります。同じ値をそのままaria-labelにも渡すので、桁が回っている途中の見た目とは別に、読み上げには最初から確定した金額だけが届きます。窓の上下は薄い霧のようにマスクして、数字が縁でふっと消えるようにしています。
回り始めるまでの間や、桁ごとの遅れの幅は、どれもスクリプトの先頭で変えられます。画面に入ったときに一度だけ回し、動きを減らす設定では最初から止まった数字で置きます。売上や利用者数、寄付額のような、注目を集めたい単一の数字の表示に向いています。
コード
<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>
.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; }
}
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);
});