数字ロール(オドメーター)

各桁が 0-9 の縦ストリップを translateY で回転させ、機械式オドメーターのように数値を更新する演出。桁ごとに僅かな遅延を付けて転がる質感を出します。カウンターやKPI表示のアクセントに。

#animation#number#counter#transform

ライブデモ

使用例(お題: 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。