【JavaScript】数字をカウントアップアニメーションさせる方法|requestAnimationFrame・イージング・IntersectionObserver発火・桁区切り対応

【JavaScript】数字をカウントアップアニメーションさせる方法|requestAnimationFrame・イージング・IntersectionObserver発火・桁区切り対応 JavaScript

サイトの実績数字、たとえば「導入企業1,200社」「満足度98%」といった数値が、画面に入った瞬間に0からスルスルと目標値まで増えていく演出を見たことがあると思います。これがカウントアップアニメーションです。数字を目立たせ、訪問者の視線を引きつける効果があります。

実装でつまずきやすいのは、setIntervalで一定間隔ずつ加算する素朴なやり方です。これは端末のリフレッシュレートや処理負荷でカクついたり、速度が環境ごとに変わったりします。正しくはrequestAnimationFrameを使い、経過時間を基準に値を計算します。

この記事では、基本のカウントアップから、自然な動きにするイージング、3桁区切りや小数の表示、画面に入ったら再生するIntersectionObserver連携、アクセシビリティ配慮、そして再利用できるクラスまでを順に作っていきます。

先に結論

  • requestAnimationFrameで、フレーム数ではなく経過時間を基準に値を求めます。これでリフレッシュレートに依存しない一定の速度になります。
  • アニメーション終了時は、計算値ではなく目標値そのものを最後に代入して端数のずれを防ぎます。
  • 自然な減速感はイージング関数(easeOutCubicなど)で付けます。
  • 表示の桁区切りや小数はIntl.NumberFormatに任せます。
  • 画面に入ったら一度だけ再生するにはIntersectionObserverを使い、発火後にunobserveします。
  • prefers-reduced-motionが有効なら、アニメーションを省略して最終値をすぐ表示します。

表示の桁区切りは数値を3桁カンマ区切りで表示する方法、スクロール連動の発火はIntersectionObserverで要素をふわっと表示させる方法で詳しく解説しています。

スポンサーリンク

requestAnimationFrameで基本のカウントアップを作る

まずは最小限の実装です。開始値から目標値まで、指定した時間(ミリ秒)をかけて数字を増やします。ポイントは、requestAnimationFrameがコールバックに渡すタイムスタンプを使い、経過時間の割合(progress)から値を計算することです。

count-up-basic.js
function countUp(element, start, end, duration) {
  let startTime = null;

  function step(timestamp) {
    if (startTime === null) {
      startTime = timestamp;
    }

    const elapsed = timestamp - startTime;
    const progress = Math.min(elapsed / duration, 1);
    const value = start + (end - start) * progress;

    element.textContent = Math.round(value).toLocaleString("ja-JP");

    if (progress < 1) {
      requestAnimationFrame(step);
    } else {
      // 最後は目標値を確定させる(端数のずれ防止)
      element.textContent = end.toLocaleString("ja-JP");
    }
  }

  requestAnimationFrame(step);
}

const el = document.getElementById("counter");
countUp(el, 0, 1200, 2000); // 0 → 1,200 を 2秒かけて

progressは0から1までの進捗割合です。Math.min(..., 1)で1を超えないように制限し、1に達したらアニメーションを止めます。終了時にendを直接代入しているのは、計算上の丸めで最終表示が「1,199」などとずれるのを防ぐためです。

なぜsetIntervalではなくrequestAnimationFrameなのか

「一定時間ごとに少しずつ足す」と考えるとsetIntervalを使いたくなりますが、カウントアップには向きません。

  • 表示と同期しないsetIntervalはブラウザの再描画タイミングと無関係に動くため、カクつきの原因になります。requestAnimationFrameは次の描画に合わせて呼ばれます。
  • 速度が環境依存になる:フレーム数で加算すると、60Hzと144Hzのディスプレイで進む速さが変わります。経過時間を基準にすれば一定です。
  • 背景タブで無駄に動くrequestAnimationFrameは非表示タブで自動的に間引かれ、負荷とバッテリーを節約します。

同じ理由から、スクロール演出など滑らかさが必要な処理ではrequestAnimationFrameが基本です。具体例はページを自動スクロールさせる方法でも扱っています。なお、デジタル時計のように1秒ごとの更新で十分なケースではsetIntervalによる時計が適しています。用途で使い分けてください。

イージングで自然な動きにする

ここまでの実装は等速(リニア)で、機械的な印象になります。最初は速く、終わりに向かってゆっくり止まる「イージング」を加えると、数字が自然に着地して見えます。progressをイージング関数に通すだけです。

count-up-easing.js
// 終わりにかけて減速するイージング
const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);

function countUp(element, start, end, duration, easing = easeOutCubic) {
  let startTime = null;

  function step(timestamp) {
    if (startTime === null) startTime = timestamp;

    const progress = Math.min((timestamp - startTime) / duration, 1);
    const eased = easing(progress);
    const value = start + (end - start) * eased;

    element.textContent = Math.round(value).toLocaleString("ja-JP");

    if (progress < 1) {
      requestAnimationFrame(step);
    } else {
      element.textContent = end.toLocaleString("ja-JP");
    }
  }

  requestAnimationFrame(step);
}

イージング関数は0〜1の進捗を受け取り、0〜1の補正値を返します。減速感を強めたいならeaseOutExpoのような関数に差し替えるだけで印象を変えられます。リニアのままにしたい場合は(t) => tを渡します。

イージングの選び方

数字の着地を自然に見せたいカウンターでは、終わりにかけて減速するeaseOut系が向いています。easeOutCubicは穏やか、easeOutExpoは最初に大きく動いて鋭く止まる印象です。easeInOut系は出だしもゆっくりになるため、カウントアップではやや眠い動きに感じられます。迷ったらeaseOutCubicから試すのがおすすめです。なお、終わりに一度行き過ぎて戻るeaseOutBack系は、目標値を超えた数字が一瞬見えるため、件数や金額のカウンターには不向きです。

数値を3桁区切りで表示しながらアニメーションさせる

毎フレームの表示整形をIntl.NumberFormatに任せると、桁区切りや小数の扱いが安定します。フォーマッタは毎フレーム作らず、一度だけ生成して使い回します。

count-up-formatter.js
function countUp(element, start, end, duration, options = {}) {
  const {
    easing = (t) => 1 - Math.pow(1 - t, 3),
    formatter = new Intl.NumberFormat("ja-JP")
  } = options;

  let startTime = null;

  function step(timestamp) {
    if (startTime === null) startTime = timestamp;

    const progress = Math.min((timestamp - startTime) / duration, 1);
    const value = start + (end - start) * easing(progress);

    element.textContent = formatter.format(Math.round(value));

    if (progress < 1) {
      requestAnimationFrame(step);
    } else {
      element.textContent = formatter.format(end);
    }
  }

  requestAnimationFrame(step);
}

整数のカウンターではMath.round(value)で丸めてから整形します。これで途中の表示が小数になってちらつくのを防げます。桁区切りやロケールの細かい指定方法は3桁カンマ区切りの記事を参照してください。

小数を含む数値をカウントアップする

「4.8」のような評価点や、パーセントの小数を扱う場合は、丸めずに小数を保持したまま整形します。表示する小数桁数をフォーマッタ側で固定するのがコツです。

count-up-decimal.js
function countUpDecimal(element, start, end, duration, decimals = 1) {
  const formatter = new Intl.NumberFormat("ja-JP", {
    minimumFractionDigits: decimals,
    maximumFractionDigits: decimals
  });
  const easing = (t) => 1 - Math.pow(1 - t, 3);
  let startTime = null;

  function step(timestamp) {
    if (startTime === null) startTime = timestamp;

    const progress = Math.min((timestamp - startTime) / duration, 1);
    const value = start + (end - start) * easing(progress);

    element.textContent = formatter.format(value); // 丸めずに渡す

    if (progress < 1) {
      requestAnimationFrame(step);
    } else {
      element.textContent = formatter.format(end);
    }
  }

  requestAnimationFrame(step);
}

countUpDecimal(document.getElementById("rating"), 0, 4.8, 1500, 1);

小数の場合はMath.round()を使わず、valueをそのままフォーマッタへ渡します。minimumFractionDigitsmaximumFractionDigitsを同じ値にすることで、桁数が一定になり表示の幅が揺れません。

画面に入ったら再生する(IntersectionObserver)

カウンターは、画面に表示されたタイミングで再生してこそ効果的です。ページ読み込み直後に画面外で動いてしまうと、訪問者は演出を見られません。IntersectionObserverで要素が見えたことを検知し、一度だけ再生します。

count-up-observer.js
function setupCounter(element) {
  const end = Number(element.dataset.count);

  const observer = new IntersectionObserver((entries, obs) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        countUp(element, 0, end, 2000);
        obs.unobserve(entry.target); // 一度だけ再生して監視解除
      }
    });
  }, { threshold: 0.5 });

  observer.observe(element);
}

document
  .querySelectorAll(".counter")
  .forEach((el) => setupCounter(el));
count-up-observer.html
<span class="counter" data-count="1200">0</span> 社
<span class="counter" data-count="98">0</span> %

threshold: 0.5は「要素の50%が見えたら発火」という意味です。発火後にunobserveを呼ぶことで、スクロールで出入りするたびに何度も再生されるのを防ぎます。目標値はdata-count属性に持たせ、HTML側で管理すると複数のカウンターをまとめて扱えます。スクロール連動の基本はIntersectionObserverで要素をふわっと表示させる方法もあわせて確認してください。

data属性の値は数値に変換する

data-countやAPIから受け取った値は文字列です。Number()で数値化せずに目標値として渡すと、計算自体は型変換で動いてしまう一方、終了時のend.toLocaleString()が文字列のメソッドになり、桁区切りされない「1200」がそのまま表示されます。目標値は必ずNumber(element.dataset.count)のように数値へ変換してから渡してください。

アクセシビリティ:prefers-reduced-motionに配慮する

動きに敏感な利用者のために、OSには「視差効果を減らす」設定があります。この設定が有効なときは、アニメーションを省略して最終値をすぐ表示するのが望ましい配慮です。

count-up-reduced-motion.js
function countUp(element, start, end, duration, options = {}) {
  const formatter =
    options.formatter ?? new Intl.NumberFormat("ja-JP");

  const prefersReduced = window.matchMedia(
    "(prefers-reduced-motion: reduce)"
  ).matches;

  // 動きを減らす設定なら、アニメーションせず最終値を表示
  if (prefersReduced) {
    element.textContent = formatter.format(end);
    return;
  }

  // 通常のアニメーション処理を続ける
  // ...(前述の step 関数)
}

この判定は関数の冒頭で行い、設定が有効ならrequestAnimationFrameを始めずに終了値を表示してreturnします。アニメーション自体が情報ではなく演出である以上、最終値さえ伝われば利用者は内容を理解できます。

再利用できるCountUpクラスにまとめる

ここまでの要素(経過時間ベース・イージング・フォーマッタ・小数・reduced-motion・キャンセル)を一つのクラスにまとめます。cancelAnimationFrameで途中停止できるようにしておくと、再生のやり直しや要素の破棄に対応できます。

count-up-class.js
class CountUp {
  constructor(element, options = {}) {
    this.element = element;
    this.start = options.start ?? 0;
    this.end = options.end ?? 0;
    this.duration = options.duration ?? 2000;
    this.easing =
      options.easing ?? ((t) => 1 - Math.pow(1 - t, 3));
    this.decimals = options.decimals ?? 0;
    this.formatter =
      options.formatter ??
      new Intl.NumberFormat("ja-JP", {
        minimumFractionDigits: this.decimals,
        maximumFractionDigits: this.decimals
      });
    this.rafId = null;
    this.startTime = null;
  }

  render(value) {
    const shown =
      this.decimals > 0 ? value : Math.round(value);
    this.element.textContent = this.formatter.format(shown);
  }

  run() {
    const prefersReduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    if (prefersReduced) {
      this.render(this.end);
      return;
    }

    this.cancel();
    this.startTime = null;

    const step = (timestamp) => {
      if (this.startTime === null) this.startTime = timestamp;

      const progress = Math.min(
        (timestamp - this.startTime) / this.duration,
        1
      );
      const value =
        this.start +
        (this.end - this.start) * this.easing(progress);

      this.render(value);

      if (progress < 1) {
        this.rafId = requestAnimationFrame(step);
      } else {
        this.render(this.end); // 目標値を確定
        this.rafId = null;
      }
    };

    this.rafId = requestAnimationFrame(step);
  }

  cancel() {
    if (this.rafId !== null) {
      cancelAnimationFrame(this.rafId);
      this.rafId = null;
    }
  }
}
count-up-class-usage.js
const counter = new CountUp(
  document.getElementById("members"),
  { end: 12500, duration: 2000 }
);

const observer = new IntersectionObserver((entries, obs) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      counter.run();
      obs.unobserve(entry.target);
    }
  });
}, { threshold: 0.5 });

observer.observe(counter.element);

run()を呼ぶたびにcancel()で前のアニメーションを止めてから再開するため、連続で呼ばれても二重に動きません。小数を扱うときはdecimalsを指定すれば、表示桁数とフォーマットが自動でそろいます。

よくある失敗

setIntervalで固定値ずつ加算してリフレッシュレート依存になる

「16msごとに+5」のような実装は、ディスプレイのリフレッシュレートや負荷で進む速さが変わります。経過時間からprogressを計算する方式にすれば、どの環境でも指定した時間でちょうど終わります。

最終値が端数でずれる

毎フレームの丸め誤差で、最後の表示が目標値より1だけ少ない、といったずれが起きます。progressが1に達したら、計算値ではなくendそのものを代入して確定させてください。

IntersectionObserverで何度も再生される

監視を解除しないと、スクロールで要素が出入りするたびに再生されます。発火したらunobserveを呼び、一度きりにします。やり直したい設計なら、再生中フラグで重複を防ぎます。

prefers-reduced-motionを無視する

動きに敏感な利用者には、激しい数字の変化が負担になります。設定が有効なときはアニメーションを省略し、最終値を即表示してください。

毎フレームIntl.NumberFormatを生成する

フォーマッタの生成にはコストがあります。step関数の中で毎回new Intl.NumberFormat()すると無駄が積み重なります。フォーマッタは一度だけ作り、使い回してください。

よくある質問

Qアニメーションの時間はどのくらいが良いですか?
A1500〜2500ミリ秒(1.5〜2.5秒)程度が一般的です。短すぎると演出が伝わらず、長すぎると待たされる印象になります。桁の大きい数値ほど、やや長めにすると見栄えが安定します。
Qライブラリ(CountUp.jsなど)を使うべきですか?
A多機能なものが必要なら既製ライブラリも選択肢ですが、本記事のようにrequestAnimationFrameとイージング、Intl.NumberFormatを組み合わせれば、依存を増やさず軽量に実装できます。要件がシンプルなら自前実装で十分です。
Qカウントダウン(減っていく演出)も同じ作り方ですか?
Aはい。startに大きい値、endに小さい値を渡せば、同じ仕組みで減少アニメーションになります。start + (end - start) * progressの式がそのまま機能します。
Q数字がガタガタ動いて見えるのはなぜですか?
A毎フレーム小数を丸めずに表示していると、桁数が変わってちらつくことがあります。整数カウンターではMath.round()で丸めてから整形し、小数では桁数をフォーマッタで固定してください。
Q複数のカウンターを別々の値で動かせますか?
Adata-count属性に各要素の目標値を持たせ、querySelectorAllでまとめて初期化すれば、1つの仕組みで複数のカウンターを個別に動かせます。

まとめ

  • requestAnimationFrameで、フレーム数ではなく経過時間を基準に値を計算します。
  • 終了時はendを直接代入し、端数のずれを防ぎます。
  • イージング関数で、自然に減速して着地する動きを付けます。
  • 桁区切りや小数はIntl.NumberFormatに任せ、フォーマッタは使い回します。
  • IntersectionObserverで画面に入ったら一度だけ再生し、unobserveします。
  • prefers-reduced-motionに配慮し、動きを減らす設定では最終値を即表示します。

カウントアップは小さな演出ですが、経過時間ベースの計算・終了値の確定・アクセシビリティ配慮という基本を押さえると、どの端末でも破綻せず気持ちよく動きます。再利用できるクラスにまとめておけば、サイト全体で安定した演出を使い回せます。