Reactで、数字が数え上がるアニメーションを作る方法

​​React​Intl.NumberFormat,Playwright,requestAnimationFrame,TypeScript,アクセシビリティ

数字を数え上げて表示するには、requestAnimationFrame で毎フレーム「始めてから何ミリ秒たったか」を測り、その割合から表示する値を計算して state に入れます。1フレームごとに 1 ずつ足すのではなく、経過時間から計算するので、決めた時間でぴったり目標の値に止まります。表示するときは、Intl.NumberFormat でカンマ区切りと小数の桁数を整えます。さらに、数字の幅をそろえる CSS を付けておくと、数えている間に数字が横に揺れません。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。時間は Playwright の clock で止め、決めたミリ秒だけ進めては表示を読み取っています(このとき requestAnimationFrame のコールバックに渡された時刻は、16ms 間隔でした)。ページを開いた直後は、時計を止めたまま実際の時間で描画を待っているため、数え始めのフレームが実行ごとにずれることがあります。同じテストを2回実行すると、StrictMode で囲んだ場合の 750ms の値が、1回目は 10,609、2回目は 10,714 でした。Firefox と Safari、スマートフォンの実機、実際のスクリーンリーダーでは試していません。

「値を更新する」ボタンを3回押す。1回目は 0 から 12,345 へ、2回目は 30,000 へ数え上がり、3回目は 8,000 へ数え下がる。数えている間だけ数字がピンクになる
ボタンを押して目標の値を変えたときの動き(上の「クリック 1回目/3回」の表示、マウスの絵、数えている間に数字をピンクにする色付け、押した直後に目標の値をピンクにする色付けは GIF 用に足したもの)

経過時間から表示する値を計算するフック

数え上げの処理は、目標の値を受け取って「いま表示する値」を返すフックにまとめます。

tsx
import { useEffect, useRef, useState } from 'react';

// 最初は速く、最後はゆっくり止まる動き(t は 0〜1)
const easeOutCubic = (t: number) => 1 - (1 - t) ** 3;

type Options = {
  duration?: number; // 何ミリ秒で数え終えるか
  start?: boolean; // false の間は数え始めない
};

export function useCountUp(target: number, { duration = 1500, start = true }: Options = {}) {
  const [value, setValue] = useState(0);
  const valueRef = useRef(0); // いま表示している値。次に数え直すときの出発点にする

  useEffect(() => {
    if (!start) return;
    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    const ms = reduce ? 0 : duration; // 動きを減らす設定なら、最初のフレームで目標の値にする
    const from = valueRef.current;
    let startTime: number | undefined;
    let frame = requestAnimationFrame(function step(now) {
      startTime ??= now;
      // 経過した時間から進み具合を出す(フレームの数では数えない)
      const t = ms === 0 ? 1 : Math.min((now - startTime) / ms, 1);
      const next = from + (target - from) * easeOutCubic(t);
      valueRef.current = next;
      setValue(next);
      if (t < 1) frame = requestAnimationFrame(step);
    });
    return () => cancelAnimationFrame(frame);
  }, [target, duration, start]);

  return value;
}

requestAnimationFrame のコールバックには、直前のフレームの描画が終わった時刻(ミリ秒)が now として渡されます。最初に呼ばれたときの時刻を startTime に取っておき、そこからの経過時間を duration で割ると、進み具合 t が 0〜1 で求まります。t をそのまま使うと一定の速さで数えますが、ここでは easeOutCubic を通して、最初は速く、終わり際はゆっくり止まるようにしています。

MDN によると、ほとんどのブラウザーでは、バックグラウンドのタブにある間は requestAnimationFrame の呼び出しが止まります。経過時間から計算しているので、タブに戻ってきたときには、その時点の経過時間に合った値(時間を過ぎていれば目標の値)から表示が再開する作りです(タブを切り替える操作は試していません)。

このフックを使った CountUp コンポーネント(後の節で紹介します)をページの一番上に置き、12,345 まで数えさせて表示を読み取りました。

text
経過 0ms: 0
経過 100ms: 1,872
経過 250ms: 4,746
経過 500ms: 8,463
経過 750ms: 10,714
経過 1000ms: 11,819
経過 1250ms: 12,275
経過 1500ms: 12,345
経過 2000ms: 12,345

半分の 750ms で目標の 9 割近くまで進み、残りをゆっくり数えて、1500ms で 12,345 に止まりました。

カンマ区切りと、数字の幅をそろえる指定

フックが返す値は 1147.601… のような小数です。表示には Intl.NumberFormat を使い、小数点以下の桁数を指定します。ja-JP で桁数を指定しないと、小数が3桁まで出ます。

text
値 1147.601234: 指定なし 1,147.601 / maximumFractionDigits: 0 1,148
値 12344.6: 指定なし 12,344.6 / maximumFractionDigits: 0 12,345

2行目のとおり、整形のときに四捨五入されます。そのため、目標の値は、数え終わる数フレーム前から表示されます。

もう1つ、数字の幅にも気を付けます。フォントによっては「1」が「8」より細いので、数えている間に数字全体の幅が変わり、中央ぞろえの数字が左右に揺れて見えます。CSS の font-variant-numeric: tabular-nums を付けると、数字が同じ幅で並びます。

css
.count-up {
  font-variant-numeric: tabular-nums; /* 数字の幅をそろえ、横に揺れないようにする */
}

48px の太字で 12,345 まで数えさせ、カンマ入りの6文字になってからの幅を、フレームごとに測りました。

text
Inter 48px 太字、5桁(カンマ入り6文字)になってからの幅: tabular-nums 1 通り(168.05〜168.05px) / 指定なし 51 通り(131.38〜162.80px)
Noto Sans CJK JP 48px 太字、5桁(カンマ入り6文字)になってからの幅: tabular-nums 1 通り(157.20〜157.20px) / 指定なし 1 通り(157.20〜157.20px)

Inter では、指定がないと幅が 51 通りに変わり、最大で 30px あまり違いました。tabular-nums を付けると 1 通りになりました。Noto Sans CJK JP は、もともと数字の幅がそろっているので、指定の有無で差はありませんでした。どのフォントで表示されても揺れないように、付けておくと安心です。

画面に入ってから数え始めるコンポーネント

ページの下のほうにある数字は、読み込んですぐに数え始めると、スクロールして見たときには数え終わっています。Intersection Observer で要素が画面に入ったことを知り、そこでフックの start を true にします。

tsx
import { useEffect, useRef, useState } from 'react';
import { useCountUp } from './useCountUp';

type Props = {
  to: number;
  duration?: number;
  decimals?: number; // 小数点以下の桁数
};

export function CountUp({ to, duration, decimals = 0 }: Props) {
  const ref = useRef<HTMLSpanElement>(null);
  const [visible, setVisible] = useState(false);

  // 画面に半分以上入ったら数え始める(一度入ったら、あとは見張らない)
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setVisible(true);
          observer.disconnect();
        }
      },
      { threshold: 0.5 },
    );
    observer.observe(ref.current!);
    return () => observer.disconnect();
  }, []);

  const value = useCountUp(to, { duration, start: visible });
  const format = new Intl.NumberFormat('ja-JP', {
    minimumFractionDigits: decimals,
    maximumFractionDigits: decimals,
  });

  return (
    <span ref={ref} className="count-up">
      {/* 動いている数字は読み上げず、最後の値だけを読み上げてもらう */}
      <span aria-hidden="true">{format.format(value)}</span>
      <span className="visually-hidden">{format.format(to)}</span>
    </span>
  );
}

ページの上から 1500px の位置に <CountUp to={12345} /> を置いて確かめました。スクロールしないまま 3000ms 待っても 0 のままで、スクロールして画面に入れると数え始めました。

text
画面の外のまま 3000ms: 0
スクロールして 100ms: 1,872
スクロールして 2000ms: 12,345

decimals={1} を渡すと、小数1桁で数えます。<CountUp to={98.6} decimals={1} /> では次のようになりました。

text
小数1桁: 250ms 37.9 / 500ms 67.6 / 1000ms 94.4 / 1500ms 98.6

途中で目標の値が変わったとき

フックは、いま表示している値を valueRef に持っておき、次に数え直すときの出発点にしています。目標の値が変わると、effect のクリーンアップで前の requestAnimationFrame を取り消してから、その時点の値から新しい目標へ数え直します。0 からやり直すと、表示が一瞬 0 に戻ってしまうためです。

1000 へ数えている途中(600ms、表示は 766)で目標を 500 に変え、50ms ごとに表示を読み取りました。

text
経過 600ms: 766
500 にしてから 50ms ごと: 750 → 718 → 697 → 677 → 658 → 640 → 624 → 610 → 596 → 580
500 にしてから 2500ms: 500

766 から 0 に戻らずに、そのまま 500 へ数え下がりました。冒頭の GIF の3回目(30,000 から 8,000)も同じ動きです。

読み上げと、動きを減らす設定への対応

数えている途中の数字は、スクリーンリーダーで読まれても意味がありません。CountUp では、動いている数字に aria-hidden="true" を付けてアクセシビリティツリーから外し、目標の値を見えない span に入れています。見えないようにする CSS は次のとおりです。

css
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

画面に 8,463 と表示されている 500ms の時点で、Playwright でアクセシビリティツリーを書き出すと、目標の値だけが入っていました。

text
経過 500ms の表示: 8,463
アクセシビリティツリー:
- text: 12,345

OS で動きを減らす設定にしている人には、数え上げを見せずに目標の値を出します。MDN の prefers-reduced-motion の説明によると、画面の動きは前庭運動障碍のある人に不快感を起こすことがあります。フックでは、この設定のときに duration を 0 として扱い、最初のフレームで目標の値にしています。Playwright で reducedMotion: 'reduce' を指定して開くと、20ms 後には 12,345 が表示されていました。

text
reduce 20ms: 12,345

再レンダーされるのは数字のコンポーネントだけ

フックは毎フレーム setValue を呼ぶので、数えている間は CountUp が再レンダーされ続けます。ページ全体を React の Profiler で囲み、表示から 2000ms の間の回数を数えました。あわせて、CountUp を包む親のコンポーネントが何回レンダーされたかも数えています。

text
レンダー回数: {"page":1,"counter":1,"commits":89}

commits は Profiler の onRender が呼ばれた回数で、89 回でした。一方、CountUp を包む親(counter)とページ(page)は、最初の1回しかレンダーされていません。state を持っているのが CountUp の中のフックなので、再レンダーはその中で収まります。React は、state が更新されたコンポーネントと、その中のコンポーネントをすべて再レンダーします(公式ドキュメントのレンダーとコミットの説明)。数え上げの値を親の state に持たせると、親の中のコンポーネントが毎フレームまとめて再レンダーされることになるので、値はこのように数字のコンポーネントの中に閉じ込めておきます。

クリーンアップで cancelAnimationFrame を呼ぶ理由

開発時の StrictMode では、effect のセットアップとクリーンアップが一度余分に実行されます。フックのクリーンアップで cancelAnimationFrame を呼ばないと、1回目のセットアップで始めたループが止まらず、数え終わるまで2つのループが同時に setValue を呼びます。

CountUp は画面に入ってから数え始めるので、表示した時点ではまだループがありません。そこで、useCountUp(12345) を直接使うコンポーネントで、requestAnimationFrame のコールバックが 2000ms の間に何回呼ばれたかを数えました。比べたのは、記事のフックと、そこからクリーンアップの return だけを外した版です。

text
Direct: コールバック 95 回 / 時刻の間隔 16ms / 2000ms 後の表示 12,345
Direct StrictMode: コールバック 95 回 / 時刻の間隔 16ms / 2000ms 後の表示 12,345
DirectNoCleanup: コールバック 95 回 / 時刻の間隔 16ms / 2000ms 後の表示 12,345
DirectNoCleanup StrictMode: コールバック 190 回 / 時刻の間隔 0, 16ms / 2000ms 後の表示 12,345

Direct が記事のフック、DirectNoCleanup がクリーンアップを外した版です。クリーンアップを外した版を StrictMode で囲んだときだけ、コールバックが 2 倍の 190 回になり、同じ時刻(間隔 0ms)に2回ずつ呼ばれていました。どちらのループも同じ値から同じ時刻に始まるので、表示はずれません。それでも setValue は毎フレーム2回ずつ呼ばれます。クリーンアップがなければ、目標の値が変わって effect がやり直されるときにも、古いループを止める処理がどこにもありません。requestAnimationFrame を使う effect では、取り消しを必ずクリーンアップに書いておきます。

​Posted by 86