数字ロール(オドメーター)
各桁が 0-9 の縦ストリップを translateY で回転させ、機械式オドメーターのように数値を更新する演出。桁ごとに僅かな遅延を付けて転がる質感を出します。カウンターやKPI表示のアクセントに。
ライブデモ
使用例(お題: SaaS FlowDesk)
この技法を「SaaS FlowDesk」というテーマのダミーサイトで実際に使った例です。
HTML
<!-- FlowDesk:KPIダッシュボードでオドメーター表示 -->
<section class="od-stage">
<header class="od-head">
<div class="od-brand"><span class="od-mark">◆</span> FlowDesk</div>
<span class="od-period">今月のダッシュボード</span>
</header>
<div class="od-main">
<p class="od-label">処理済みワークフロー</p>
<!-- 桁は JS で生成(各桁=0-9の縦ストリップ) -->
<div class="od-counter" id="odCounter" aria-live="polite"></div>
<p class="od-delta" id="odDelta">前月比 +12.4%</p>
</div>
<div class="od-cards">
<div class="od-card"><span class="od-c-num">98.6%</span><span class="od-c-lab">稼働率</span></div>
<div class="od-card"><span class="od-c-num">1.2s</span><span class="od-c-lab">平均応答</span></div>
<div class="od-card"><span class="od-c-num">342</span><span class="od-c-lab">アクティブ</span></div>
</div>
<button class="od-btn" id="odBtn" type="button">⟳ データ更新</button>
</section>
CSS
/* FlowDesk:KPIオドメーター */
:root {
--navy: #0f1b34;
--blue: #4f7cff;
}
* { box-sizing: border-box; }
body {
margin: 0;
height: 400px;
font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
background: radial-gradient(120% 120% at 50% -10%, #1a2c50 0%, #0f1b34 60%, #0a1228 100%);
color: #eef2ff;
}
.od-stage { height: 400px; padding: 18px 22px; display: flex; flex-direction: column; }
.od-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.od-brand { font-size: 14px; font-weight: 800; letter-spacing: 0.04em; }
.od-mark { color: var(--blue); }
.od-period { font-size: 11px; color: rgba(255, 255, 255, 0.55); }
.od-main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.od-label { margin: 0 0 14px; font-size: 12px; letter-spacing: 0.08em; color: #9db4ff; }
/* オドメーター本体 */
.od-counter { display: inline-flex; gap: 4px; }
.od-digit {
position: relative;
width: 34px;
height: 52px;
overflow: hidden;
border-radius: 8px;
background: linear-gradient(180deg, rgba(255,255,255,0.1), rgba(255,255,255,0.03));
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: inset 0 -8px 14px rgba(0,0,0,0.35), inset 0 8px 14px rgba(0,0,0,0.25);
}
/* 縦ストリップ 0-9:translateY で回す */
.od-strip {
position: absolute;
top: 0; left: 0; right: 0;
display: flex;
flex-direction: column;
transition: transform 1s cubic-bezier(0.22, 1, 0.36, 1);
}
.od-strip span {
height: 52px;
display: grid;
place-items: center;
font-size: 30px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #eaf0ff;
}
.od-sep { align-self: center; font-size: 26px; font-weight: 800; color: rgba(255,255,255,0.5); }
.od-delta { margin: 14px 0 0; font-size: 12px; color: #6fe0a8; }
.od-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }
.od-card {
display: flex; flex-direction: column; gap: 2px;
padding: 9px 10px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.09);
}
.od-c-num { font-size: 15px; font-weight: 800; }
.od-c-lab { font-size: 10px; color: rgba(255, 255, 255, 0.55); }
.od-btn {
width: 100%;
font: inherit; font-size: 12px; font-weight: 700;
padding: 10px; border: none; border-radius: 10px; cursor: pointer;
color: #fff; background: linear-gradient(135deg, #5f8bff, var(--blue));
box-shadow: 0 8px 18px rgba(79, 124, 255, 0.4);
transition: transform 0.1s ease, box-shadow 0.2s ease;
}
.od-btn:hover { box-shadow: 0 12px 24px rgba(79, 124, 255, 0.55); }
.od-btn:active { transform: scale(0.98); }
@media (prefers-reduced-motion: reduce) {
.od-strip { transition: none; }
}
JavaScript
// FlowDesk:KPI数値を各桁の縦ストリップで転がす
(() => {
const counter = document.getElementById("odCounter");
const btn = document.getElementById("odBtn");
const delta = document.getElementById("odDelta");
if (!counter) return; // null安全
const DIGITS = 5; // 表示桁数
const strips = [];
// 桁ストリップ(0-9)を構築
const build = () => {
counter.innerHTML = "";
strips.length = 0;
for (let i = 0; i < DIGITS; i++) {
// 3桁ごとに区切りを挿入
if (i > 0 && (DIGITS - i) % 3 === 0) {
const sep = document.createElement("span");
sep.className = "od-sep";
sep.textContent = ",";
counter.appendChild(sep);
}
const digit = document.createElement("div");
digit.className = "od-digit";
const strip = document.createElement("div");
strip.className = "od-strip";
for (let n = 0; n <= 9; n++) {
const s = document.createElement("span");
s.textContent = String(n);
strip.appendChild(s);
}
digit.appendChild(strip);
counter.appendChild(digit);
strips.push(strip);
}
};
// 数値を各桁に反映(桁ごとに僅かな遅延で転がる質感)
const setValue = (value) => {
const str = String(value).padStart(DIGITS, "0").slice(-DIGITS);
strips.forEach((strip, i) => {
const d = Number(str[i]) || 0;
setTimeout(() => {
strip.style.transform = `translateY(${-d * 52}px)`;
}, i * 80);
});
};
build();
// 初期値 → 少し後に更新して回転を見せる
setValue(12480);
setTimeout(() => setValue(13947), 600);
if (btn) {
btn.addEventListener("click", () => {
const next = 12000 + Math.floor(Math.random() * 7000);
setValue(next);
if (delta) {
const pct = (Math.random() * 18 + 2).toFixed(1);
delta.textContent = `前月比 +${pct}%`;
}
});
}
})();
コード
HTML
<!-- 数字ロール:各桁の縦ストリップを translateY で回し機械式オドメーター風に -->
<div class="odo-stage">
<p class="odo-label">TOTAL VIEWS</p>
<div class="odo-counter" id="odoCounter" aria-live="polite">
<!-- 桁は JS で生成(各桁=0-9 の縦ストリップ) -->
</div>
<button class="odo-btn" id="odoBtn" type="button">⟳ ランダム更新</button>
</div>
CSS
/* 暗い盤面に並ぶ数字桁を縦回転で更新するオドメーター */
:root {
--digit-h: 56px; /* 1桁の高さ(=1数字の高さ) */
--digit-w: 38px; /* 1桁の幅 */
--roll: 1.05s; /* 回転の基本時間 */
--accent: #39d3ff;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 360px;
display: grid;
place-items: center;
font-family: "Segoe UI", "Hiragino Sans", "Yu Gothic UI", system-ui, sans-serif;
background:
radial-gradient(120% 120% at 50% 0%, #1b2138 0%, #0a0c16 72%);
color: #eef0ff;
}
.odo-stage {
width: min(420px, 90vw);
padding: 20px;
text-align: center;
}
.odo-label {
margin: 0 0 14px;
font-size: 11px;
letter-spacing: .22em;
color: #8a92c9;
font-family: "Consolas", "SFMono-Regular", monospace;
}
/* 桁を横並びにするカウンター本体 */
.odo-counter {
display: inline-flex;
gap: 5px;
padding: 12px 14px;
border-radius: 14px;
background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(0,0,0,.25));
border: 1px solid rgba(255,255,255,.08);
box-shadow: inset 0 2px 8px rgba(0,0,0,.45);
}
/* 1桁:0-9 ストリップを覗く窓 */
.odo-digit {
position: relative;
width: var(--digit-w);
height: var(--digit-h);
overflow: hidden;
border-radius: 6px;
background: rgba(0,0,0,.35);
}
/* 上下のシェードで筒の中らしさを演出 */
.odo-digit::before {
content: "";
position: absolute; inset: 0;
pointer-events: none;
z-index: 2;
background: linear-gradient(180deg,
rgba(10,12,22,.9) 0%, transparent 28%,
transparent 72%, rgba(10,12,22,.9) 100%);
}
/* 区切り(カンマ)はストリップを持たない */
.odo-digit.is-sep {
width: 14px;
background: transparent;
}
.odo-digit.is-sep::before { display: none; }
.odo-sep {
position: absolute;
bottom: 6px; left: 0; right: 0;
text-align: center;
font-size: 30px; font-weight: 700;
color: #5a628f;
}
/* 縦に積んだ 0-9(×複数巻)のストリップ */
.odo-strip {
position: absolute;
left: 0; right: 0; top: 0;
display: flex;
flex-direction: column;
will-change: transform;
/* 初期は遷移なし。更新時に JS が transition を付与 */
}
.odo-num {
height: var(--digit-h);
line-height: var(--digit-h);
font-size: 32px;
font-weight: 700;
text-align: center;
font-variant-numeric: tabular-nums;
color: #eef0ff;
text-shadow: 0 0 10px rgba(57,211,255,.25);
}
.odo-btn {
margin-top: 20px;
padding: 10px 18px;
border: 1px solid rgba(57,211,255,.4);
border-radius: 10px;
background: rgba(57,211,255,.16);
color: #e7faff;
font-size: 13px;
cursor: pointer;
transition: background .2s ease, transform .1s ease;
}
.odo-btn:hover { background: rgba(57,211,255,.28); }
.odo-btn:active { transform: scale(.97); }
/* モーション控えめ設定では一瞬で切り替え */
@media (prefers-reduced-motion: reduce) {
.odo-strip { transition: none !important; }
}
JavaScript
// 数字ロール:各桁に 0-9 の縦ストリップを敷き、translateY で目的の数字へ回す
(() => {
const counter = document.getElementById('odoCounter');
const btn = document.getElementById('odoBtn');
if (!counter) return; // null安全
const DIGITS = 6; // 表示桁数
// CSS変数から1数字の高さ・基本時間を取得
const cs = getComputedStyle(document.documentElement);
const digitH = parseInt(cs.getPropertyValue('--digit-h'), 10) || 56;
const baseRoll = parseFloat(cs.getPropertyValue('--roll')) || 1.05;
// モーション控えめ設定
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 1桁ストリップ:0-9 を REPEAT 回ぶん縦に積み、下方向へ回り込めるようにする
const REPEAT = 6;
const strips = []; // { strip要素, pos:現在の論理オフセット(0..) }
// 1桁分のDOMを生成(isSep ならカンマ区切り)
const buildDigit = (isSep) => {
const cell = document.createElement('div');
cell.className = 'odo-digit' + (isSep ? ' is-sep' : '');
if (isSep) {
cell.innerHTML = '<span class="odo-sep">,</span>';
return { cell, strip: null };
}
const strip = document.createElement('div');
strip.className = 'odo-strip';
let html = '';
for (let r = 0; r < REPEAT; r++) {
for (let n = 0; n < 10; n++) html += `<span class="odo-num">${n}</span>`;
}
strip.innerHTML = html;
cell.appendChild(strip);
return { cell, strip };
};
// 桁を並べる(中央にカンマ:"###,###")
counter.innerHTML = '';
for (let i = 0; i < DIGITS; i++) {
if (i === 3) counter.appendChild(buildDigit(true).cell);
const d = buildDigit(false);
counter.appendChild(d.cell);
strips.push({ strip: d.strip, pos: 0 });
}
// 数値→桁配列(左ゼロ埋め)
const toDigits = (value) => String(value).padStart(DIGITS, '0').split('').map(Number);
// 各桁を目標数字へ回す。pos は常に下方向へ前進させ、見た目は target で止める
const roll = (value, animate) => {
const targets = toDigits(value);
strips.forEach((s, i) => {
const target = targets[i];
const curDigit = ((s.pos % 10) + 10) % 10; // 今見えている数字
let advance = (target - curDigit + 10) % 10; // 下方向の最短前進
if (animate && advance === 0) advance = 10; // 同数字でも一周見せる
let next = s.pos + advance;
// ストリップ末尾に達しそうなら、まず瞬時に同じ見た目の上側へ巻き戻す
if (next > (REPEAT - 1) * 10) {
s.strip.style.transition = 'none';
s.pos = curDigit; // 0..9 の最初の周へリセット(見た目は不変)
s.strip.style.transform = `translateY(${-s.pos * digitH}px)`;
void s.strip.offsetHeight; // リフロー強制で next の遷移を有効化
next = s.pos + advance;
}
s.pos = next;
// 桁ごとに僅かな遅延(右が先に、左ほど遅れて止まる)と段階的な時間
const delay = animate ? (DIGITS - 1 - i) * 0.08 : 0;
const dur = animate && !reduced ? baseRoll + i * 0.12 : 0;
s.strip.style.transition = dur
? `transform ${dur}s cubic-bezier(.22,1,.36,1) ${delay}s`
: 'none';
s.strip.style.transform = `translateY(${-s.pos * digitH}px)`;
});
};
// ランダムな6桁値で更新
const update = () => {
const value = Math.floor(Math.random() * 1000000); // 0 .. 999999
roll(value, true);
};
if (btn) btn.addEventListener('click', update);
// 初期表示:0 を置いてから一度転がす
roll(0, false);
requestAnimationFrame(() => requestAnimationFrame(update));
})();
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「数字ロール(オドメーター)」の効果を追加してください。
# 追加してほしい効果
数字ロール(オドメーター)(アニメーション & トランジション)
各桁が 0-9 の縦ストリップを translateY で回転させ、機械式オドメーターのように数値を更新する演出。桁ごとに僅かな遅延を付けて転がる質感を出します。カウンターやKPI表示のアクセントに。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 数字ロール:各桁の縦ストリップを translateY で回し機械式オドメーター風に -->
<div class="odo-stage">
<p class="odo-label">TOTAL VIEWS</p>
<div class="odo-counter" id="odoCounter" aria-live="polite">
<!-- 桁は JS で生成(各桁=0-9 の縦ストリップ) -->
</div>
<button class="odo-btn" id="odoBtn" type="button">⟳ ランダム更新</button>
</div>
【CSS】
/* 暗い盤面に並ぶ数字桁を縦回転で更新するオドメーター */
:root {
--digit-h: 56px; /* 1桁の高さ(=1数字の高さ) */
--digit-w: 38px; /* 1桁の幅 */
--roll: 1.05s; /* 回転の基本時間 */
--accent: #39d3ff;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 360px;
display: grid;
place-items: center;
font-family: "Segoe UI", "Hiragino Sans", "Yu Gothic UI", system-ui, sans-serif;
background:
radial-gradient(120% 120% at 50% 0%, #1b2138 0%, #0a0c16 72%);
color: #eef0ff;
}
.odo-stage {
width: min(420px, 90vw);
padding: 20px;
text-align: center;
}
.odo-label {
margin: 0 0 14px;
font-size: 11px;
letter-spacing: .22em;
color: #8a92c9;
font-family: "Consolas", "SFMono-Regular", monospace;
}
/* 桁を横並びにするカウンター本体 */
.odo-counter {
display: inline-flex;
gap: 5px;
padding: 12px 14px;
border-radius: 14px;
background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(0,0,0,.25));
border: 1px solid rgba(255,255,255,.08);
box-shadow: inset 0 2px 8px rgba(0,0,0,.45);
}
/* 1桁:0-9 ストリップを覗く窓 */
.odo-digit {
position: relative;
width: var(--digit-w);
height: var(--digit-h);
overflow: hidden;
border-radius: 6px;
background: rgba(0,0,0,.35);
}
/* 上下のシェードで筒の中らしさを演出 */
.odo-digit::before {
content: "";
position: absolute; inset: 0;
pointer-events: none;
z-index: 2;
background: linear-gradient(180deg,
rgba(10,12,22,.9) 0%, transparent 28%,
transparent 72%, rgba(10,12,22,.9) 100%);
}
/* 区切り(カンマ)はストリップを持たない */
.odo-digit.is-sep {
width: 14px;
background: transparent;
}
.odo-digit.is-sep::before { display: none; }
.odo-sep {
position: absolute;
bottom: 6px; left: 0; right: 0;
text-align: center;
font-size: 30px; font-weight: 700;
color: #5a628f;
}
/* 縦に積んだ 0-9(×複数巻)のストリップ */
.odo-strip {
position: absolute;
left: 0; right: 0; top: 0;
display: flex;
flex-direction: column;
will-change: transform;
/* 初期は遷移なし。更新時に JS が transition を付与 */
}
.odo-num {
height: var(--digit-h);
line-height: var(--digit-h);
font-size: 32px;
font-weight: 700;
text-align: center;
font-variant-numeric: tabular-nums;
color: #eef0ff;
text-shadow: 0 0 10px rgba(57,211,255,.25);
}
.odo-btn {
margin-top: 20px;
padding: 10px 18px;
border: 1px solid rgba(57,211,255,.4);
border-radius: 10px;
background: rgba(57,211,255,.16);
color: #e7faff;
font-size: 13px;
cursor: pointer;
transition: background .2s ease, transform .1s ease;
}
.odo-btn:hover { background: rgba(57,211,255,.28); }
.odo-btn:active { transform: scale(.97); }
/* モーション控えめ設定では一瞬で切り替え */
@media (prefers-reduced-motion: reduce) {
.odo-strip { transition: none !important; }
}
【JavaScript】
// 数字ロール:各桁に 0-9 の縦ストリップを敷き、translateY で目的の数字へ回す
(() => {
const counter = document.getElementById('odoCounter');
const btn = document.getElementById('odoBtn');
if (!counter) return; // null安全
const DIGITS = 6; // 表示桁数
// CSS変数から1数字の高さ・基本時間を取得
const cs = getComputedStyle(document.documentElement);
const digitH = parseInt(cs.getPropertyValue('--digit-h'), 10) || 56;
const baseRoll = parseFloat(cs.getPropertyValue('--roll')) || 1.05;
// モーション控えめ設定
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 1桁ストリップ:0-9 を REPEAT 回ぶん縦に積み、下方向へ回り込めるようにする
const REPEAT = 6;
const strips = []; // { strip要素, pos:現在の論理オフセット(0..) }
// 1桁分のDOMを生成(isSep ならカンマ区切り)
const buildDigit = (isSep) => {
const cell = document.createElement('div');
cell.className = 'odo-digit' + (isSep ? ' is-sep' : '');
if (isSep) {
cell.innerHTML = '<span class="odo-sep">,</span>';
return { cell, strip: null };
}
const strip = document.createElement('div');
strip.className = 'odo-strip';
let html = '';
for (let r = 0; r < REPEAT; r++) {
for (let n = 0; n < 10; n++) html += `<span class="odo-num">${n}</span>`;
}
strip.innerHTML = html;
cell.appendChild(strip);
return { cell, strip };
};
// 桁を並べる(中央にカンマ:"###,###")
counter.innerHTML = '';
for (let i = 0; i < DIGITS; i++) {
if (i === 3) counter.appendChild(buildDigit(true).cell);
const d = buildDigit(false);
counter.appendChild(d.cell);
strips.push({ strip: d.strip, pos: 0 });
}
// 数値→桁配列(左ゼロ埋め)
const toDigits = (value) => String(value).padStart(DIGITS, '0').split('').map(Number);
// 各桁を目標数字へ回す。pos は常に下方向へ前進させ、見た目は target で止める
const roll = (value, animate) => {
const targets = toDigits(value);
strips.forEach((s, i) => {
const target = targets[i];
const curDigit = ((s.pos % 10) + 10) % 10; // 今見えている数字
let advance = (target - curDigit + 10) % 10; // 下方向の最短前進
if (animate && advance === 0) advance = 10; // 同数字でも一周見せる
let next = s.pos + advance;
// ストリップ末尾に達しそうなら、まず瞬時に同じ見た目の上側へ巻き戻す
if (next > (REPEAT - 1) * 10) {
s.strip.style.transition = 'none';
s.pos = curDigit; // 0..9 の最初の周へリセット(見た目は不変)
s.strip.style.transform = `translateY(${-s.pos * digitH}px)`;
void s.strip.offsetHeight; // リフロー強制で next の遷移を有効化
next = s.pos + advance;
}
s.pos = next;
// 桁ごとに僅かな遅延(右が先に、左ほど遅れて止まる)と段階的な時間
const delay = animate ? (DIGITS - 1 - i) * 0.08 : 0;
const dur = animate && !reduced ? baseRoll + i * 0.12 : 0;
s.strip.style.transition = dur
? `transform ${dur}s cubic-bezier(.22,1,.36,1) ${delay}s`
: 'none';
s.strip.style.transform = `translateY(${-s.pos * digitH}px)`;
});
};
// ランダムな6桁値で更新
const update = () => {
const value = Math.floor(Math.random() * 1000000); // 0 .. 999999
roll(value, true);
};
if (btn) btn.addEventListener('click', update);
// 初期表示:0 を置いてから一度転がす
roll(0, false);
requestAnimationFrame(() => requestAnimationFrame(update));
})();
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。