【JavaScript】カウントアップアニメーションを実装する

javascript-icon JavaScript

こんにちは、かつコーチです。

前回は画像の遅延読み込み(Lazy Load)を扱いました。

今回は「実践UI実装」シリーズの新しいテーマとして、数字がパラパラと増えていくカウントアップアニメーションを作っていきます。

実績数値や統計データを見せるセクションでよく使われる演出で、「導入企業数」「累計利用者数」といった数字を印象的に見せたいときに重宝します。

カウントアップアニメーションとは?

どんな場面で使われるのか

トップページの実績紹介や、LPの数字訴求セクションでよく見かける演出です。

ページを開いた瞬間や、その要素がスクロールで画面に入ってきた瞬間に「0」から目標の数字までカウントアップします。

静的にただ数字を表示するより、視線を引きつけやすく、数字の説得力も増す効果があります。

実装の基本方針

やることはシンプルで、次の2つの仕組みの組み合わせです。

  • 一定時間の間、数値を少しずつ増やして表示し続ける
  • スクロールで要素が見えたタイミングを検知して、アニメーションを開始する

数値を増やす処理にはrequestAnimationFrame、画面に入ったかどうかの検知にはIntersection Observerを使います。

どちらもブラウザ標準のAPIなので、ライブラリなしで実装できます。

基本の実装手順

手順1:HTMLとCSSを用意する

まずは表示する数字の器を作ります。

<div class="stats">
  <div class="stat-item">
    <span class="stat-number" data-target="1200">0</span>
    <span class="stat-label">導入企業数</span>
  </div>
  <div class="stat-item">
    <span class="stat-number" data-target="98">0</span>
    <span class="stat-label">継続率(%)</span>
  </div>
</div>
.stats {
  display: flex;
  gap: 40px;
  justify-content: center;
  padding: 40px 0;
}
.stat-item {
  text-align: center;
}
.stat-number {
  display: block;
  font-size: 2.5rem;
  font-weight: bold;
  color: #2563eb;
}
.stat-label {
  font-size: 0.9rem;
  color: #555;
}

data-target属性に、最終的に表示したい数値を持たせておくのがポイントです。

HTML側にデータを持たせておくことで、JavaScript側は「このdata属性の数字まで増やす」というシンプルな処理に集中できます。

手順2:requestAnimationFrameで数値を増やす

続いて、実際にカウントアップさせる関数を作ります。

function countUp(el, target, duration = 1500) {
  const startTime = performance.now();

  function update(currentTime) {
    const elapsed = currentTime - startTime;
    const progress = Math.min(elapsed / duration, 1);
    const value = Math.floor(progress * target);

    el.textContent = value.toLocaleString();

    if (progress < 1) {
      requestAnimationFrame(update);
    } else {
      el.textContent = target.toLocaleString();
    }
  }

  requestAnimationFrame(update);
}

elapsed / durationで「経過時間の割合(0〜1)」を出し、それを目標値に掛けることで、経過時間に応じた現在値を計算しています。

toLocaleString()を使うと、12001,200のようにカンマ区切りで表示されるので、桁数の多い数字ほど見やすくなります。

手順3:Intersection Observerでスクロール検知と組み合わせる

最後に、要素が画面内に入ったタイミングでカウントアップを開始する処理をつなげます。

const targets = document.querySelectorAll(".stat-number");

const observer = new IntersectionObserver(
  (entries, obs) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const el = entry.target;
        const target = Number(el.dataset.target);
        countUp(el, target);
        obs.unobserve(el); // 一度カウントしたら監視を終了する
      }
    });
  },
  { threshold: 0.5 }
);

targets.forEach((el) => observer.observe(el));

obs.unobserve(el)を忘れずに呼んでいる点に注目してください。

これを書かないと、要素が画面の中で見えたり隠れたりするたびにカウントアップが何度も走ってしまいます。

よくあるつまずきポイント・エラー対処

つまずき1:setIntervalで実装して速度がガタガタになった

私が最初にこの機能を作ったとき、requestAnimationFrameを知らずsetIntervalで数値を1つずつ増やす実装をしていました。

❌ Before:setIntervalで固定間隔ずつ加算する

function countUpBad(el, target) {
  let current = 0;
  const step = Math.ceil(target / 100);

  const timer = setInterval(() => {
    current += step;
    if (current >= target) {
      current = target;
      clearInterval(timer);
    }
    el.textContent = current;
  }, 15);
}

一見動いているように見えるのですが、実機のスマホで動かすと処理が重くなった瞬間にカウントの速度が乱れ、カクカクした動きになってしまいました。

setIntervalは「指定時間ごとに実行する」ことしか保証しておらず、実際の経過時間とはズレが生じやすいのが原因です。

✅ After:requestAnimationFrameで経過時間ベースに計算する

function countUpGood(el, target, duration = 1500) {
  const startTime = performance.now();

  function update(now) {
    const progress = Math.min((now - startTime) / duration, 1);
    el.textContent = Math.floor(progress * target).toLocaleString();
    if (progress < 1) requestAnimationFrame(update);
  }

  requestAnimationFrame(update);
}

performance.now()で取得した「開始時刻」と「現在時刻」の差分から進捗を計算する方式に変えたことで、端末の性能に関わらず同じduration(今回は1.5秒)でアニメーションが完了するようになりました。

体感速度が一定になるだけで、演出としての完成度がぐっと上がります。

つまずき2:ページ読み込み時にすでに画面内の要素が発火しない

もう一つよくあるのが、ファーストビュー内にある数字がIntersection Observerの初期状態でうまく検知されないケースです。

これは実装ミスというより、thresholdの値や要素の高さによって発生することがあるため、ファーストビュー内の数字だけはDOMContentLoaded時に直接カウントアップを呼び出す、といった保険をかけておくと安全です。

window.addEventListener("DOMContentLoaded", () => {
  document.querySelectorAll(".stat-number").forEach((el) => observer.observe(el));
});

読み込み完了後に監視を開始するようにするだけで、初期表示のタイミング問題はかなり減らせます。

応用・一歩先の使い方

小数点や単位付きの数値に対応する

data-targetが小数や「%」付きの場合は、フォーマット部分を関数化しておくと再利用しやすくなります。

function countUp(el, target, duration = 1500, formatter = (v) => v.toLocaleString()) {
  const startTime = performance.now();
  function update(now) {
    const progress = Math.min((now - startTime) / duration, 1);
    el.textContent = formatter(Math.floor(progress * target));
    if (progress < 1) requestAnimationFrame(update);
  }
  requestAnimationFrame(update);
}

// 使用例:末尾に%を付ける
countUp(document.querySelector(".stat-number"), 98, 1500, (v) => `${v}%`);

第4引数に整形用の関数を渡せるようにしておくことで、「円」「%」「+」など、どんな単位でも同じ関数を使い回せます。

複数の数字を少しずつタイミングをずらして開始する

複数の統計値を並べるときは、setTimeoutで開始タイミングを少しずつずらすと、演出にリズムが生まれます。

targets.forEach((el, index) => {
  setTimeout(() => countUp(el, Number(el.dataset.target)), index * 150);
});

同時に動かすより、少し時差をつけた方が視線が自然に流れていくので、数字が複数並ぶセクションではぜひ試してみてください。

まとめ

この記事のポイント

  • カウントアップアニメーションはrequestAnimationFrameとIntersection Observerの組み合わせで実装できる
  • setIntervalではなく経過時間ベースで進捗を計算することで、端末性能に左右されない滑らかな動きになる
  • obs.unobserve()を忘れると、スクロールのたびに何度もカウントし直してしまう
  • toLocaleString()でカンマ区切り、フォーマット関数で単位付きにも柔軟に対応できる

次に読むべき記事

次回は、操作結果をさりげなく伝える「トースト通知」の実装を扱います。

→ 次の記事:トースト通知を実装する

タイトルとURLをコピーしました