【Next.js】数字のカウントアップの作り方を解説|画面に入ったら1回だけ動かす

公開日: 2026年9月12日 · 読了目安 9 分

Next.js で導入社数や満足度などの数字をカウントアップさせる方法です。ライブラリなしの実装、Framer Motion(motion)版、動きを減らす設定と読み上げへの配慮、数字が弱く見える原因までコード付きで解説します。

数字のカウントアップはいつ何回動かすべきか

Next.js の LP に「導入社数 1,200社」のような実績を並べるとき、0 から数字が増えていく演出を付けたくなります。

作り方の答えは、画面に入った瞬間に1回だけ、1秒前後で最終値まで増やすことです。

ただし、数字の説得力を決めるのは動きよりも、単位と「何の数字か」というキャプションです。

この記事では、カウントアップの実装と、数字そのものを強く見せる組み方の両方を扱います。

  • ライブラリなしのカウントアップ(ブラウザに元からある機能だけで作る)

  • Motion(旧 Framer Motion)で書く場合

  • 動きを減らしたい人と、スクリーンリーダーへの配慮

  • 数字が弱く見える原因と、3つに絞った実績セクションの例

数字のカウントアップを1回だけにする理由

数字の演出は、読者の目を数字に向けるためのものです。目的を果たしたあとも動き続けると、逆に読む邪魔になります。

何度も0に戻ると、数字が覚えられない

スクロールで行き来するたびに0から数え直すと、読者は最終値を見る前に通り過ぎます。一度最終値になったら、そのまま止めておきます。

ヒーローでカウントアップすると最初の表示が遅く見える

ページを開いた直後に数字が動くと、見出しやボタンより先に数字へ目が行きます。最も大きな要素の表示(LCP)にも影響しやすくなります。

実績の数字は、ヒーローの下に置くのが基本です。

動きを減らしたい人もいる

OS の設定で「視差効果を減らす」を選んでいる人には、prefers-reduced-motion: reduce が届きます。この場合は、最初から最終値を表示します。

ライブラリなしで数字のカウントアップを作るコード

まずはライブラリを使わずに作ります。Intersection Observer は、要素が画面に入ったかを知らせるブラウザの機能です。

これで開始のタイミングを取り、requestAnimationFrame(画面の書き換えに合わせて処理を呼ぶ機能)で数字を少しずつ更新します。

工夫は次の3つです。

  • サーバーで描画するときは最終値を出しておく

  • 読み上げ用には最終値だけを渡す

  • tabular-nums で数字の幅を揃えて横揺れを防ぐ

tsx
// components/CountUp.tsx
  "use client";

  import { useEffect, useRef, useState } from "react";

  type Props = {
    value: number;
    suffix?: string;
    decimals?: number;
    duration?: number;
  };

  export function CountUp({ value, suffix = "", decimals = 0, duration = 1200 }: Props) {
    const ref = useRef<HTMLSpanElement>(null);
    const [display, setDisplay] = useState(value);

    const format = (n: number) =>
      new Intl.NumberFormat("ja-JP", {
        minimumFractionDigits: decimals,
        maximumFractionDigits: decimals,
      }).format(n);

    useEffect(() => {
      const el = ref.current;
      if (!el) return;
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

      setDisplay(0);
      let frame = 0;
      const observer = new IntersectionObserver(
        ([entry]) => {
          if (!entry.isIntersecting) return;
          observer.disconnect();
          const start = performance.now();
          const tick = (now: number) => {
            const t = Math.min((now - start) / duration, 1);
            const eased = 1 - Math.pow(1 - t, 3);
            setDisplay(value * eased);
            if (t < 1) frame = requestAnimationFrame(tick);
          };
          frame = requestAnimationFrame(tick);
        },
        { threshold: 0.6 },
      );
      observer.observe(el);

      return () => {
        observer.disconnect();
        cancelAnimationFrame(frame);
      };
    }, [value, duration]);

    return (
      <span ref={ref} className="tabular-nums">
        <span className="sr-only">
          {format(value)}
          {suffix}
        </span>
        <span aria-hidden="true">
          {format(display)}
          {suffix}
        </span>
      </span>
    );
  }

カウントアップのコードで押さえるポイント

  • 状態を覚える仕組み(useState)の最初の値を、最終値にしている。そのため、サーバーで作る HTML には最終値が入る

  • 1回数えたら、画面に入ったかの見張りをやめる(observer.disconnect())

  • 1 - (1 - t)^3 という計算で、最後にゆっくり止める

  • Intl.NumberFormat で、1,200 のようなカンマ区切りと小数の桁を揃える

Framer Motion(motion)でカウントアップを書く場合

すでに Motion を使っているなら、画面に入ったかを知る useInView と、数値を動かす animate で短く書けます。

現在のパッケージ名は motion で、読み込み元は "motion/react" です。framer-motion を入れている場合は、"framer-motion" から同じ名前で読み込めます。

最初の値を最終値にしているので、サーバーの HTML にも最終値が入ります。動きを減らす設定の人には、数えずに最終値のまま見せます。

読み上げにも最終値だけを渡すなら、上のライブラリなし版と同じく sr-only の span を足してください。

tsx
"use client";

  import { animate, useInView, useReducedMotion } from "motion/react";
  import { useEffect, useRef, useState } from "react";

  export function MotionCountUp({ value }: { value: number }) {
    const ref = useRef<HTMLSpanElement>(null);
    const inView = useInView(ref, { once: true, amount: 0.6 });
    const reduce = useReducedMotion();
    const [display, setDisplay] = useState(value);

    useEffect(() => {
      if (reduce || !inView) return;
      const controls = animate(0, value, {
        duration: 1.2,
        ease: "easeOut",
        onUpdate: (v) => setDisplay(Math.round(v)),
      });
      return () => controls.stop();
    }, [inView, reduce, value]);

    return (
      <span ref={ref} className="tabular-nums">
        {display.toLocaleString("ja-JP")}
      </span>
    );
  }

カウントアップ付きの実績セクションを組む

前の章で作った CountUp を3つ並べ、数字の下にキャプション、セクションの下に出典を置きます。数字は大きく、単位は少し小さくすると、数字そのものが先に目に入ります。

HTML は、用語と説明の組を表す dl タグで書きます。中には、何の数字か(dt)と値(dd)の順に入れます。

見た目だけ数字を上にするため、並び順を逆にする flex-col-reverse を使っています。

読み上げでは「導入企業数、1,200社」の順に伝わります。

tsx
// components/StatsSection.tsx
  import { CountUp } from "./CountUp";

  const stats = [
    { value: 1200, suffix: "社", label: "導入企業数(2026年9月時点)" },
    { value: 98.2, suffix: "%", label: "サポート満足度(導入企業アンケート)", decimals: 1 },
    { value: 40, suffix: "時間", label: "1社あたりの月間削減時間(平均)" },
  ];

  export function StatsSection() {
    return (
      <section className="mx-auto max-w-5xl px-6 py-24">
        <dl className="grid gap-10 text-center sm:grid-cols-3">
          {stats.map((s) => (
            <div key={s.label} className="flex flex-col-reverse">
              <dt className="mt-2 text-sm text-zinc-500">{s.label}</dt>
              <dd className="text-5xl font-semibold tracking-tight">
                <CountUp value={s.value} suffix={s.suffix} decimals={s.decimals} />
              </dd>
            </div>
          ))}
        </dl>
        <p className="mt-10 text-center text-xs text-zinc-400">
          出典:自社調べ(2026年9月)
        </p>
      </section>
    );
  }

サンプルの数字はそのまま公開しない

上の 1,200社や 98.2% は、説明のための例です。

AI エージェントに作らせたセクションや、コピーしてきたコードにも、もっともらしい数字が入っていることがあります。公開前に、一つずつ自社の実データに差し替えてください。

カウントアップしても数字が弱く見える原因

カウントアップを付けても伝わらないときは、たいてい動きではなく数字の見せ方に原因があります。

  • キャプションがあいまい:「1,200」だけでは導入社数か利用者数か分からない。何の数字かを一言で書く

  • 並べすぎ:4つ以上並ぶと、どれも覚えられない。強い順に3つまで

  • 出典や時点がない:大きな数字ほど疑われる。「2026年9月時点」「自社調べ(導入企業120社へのアンケート)」のように、時点と調べ方を添える

  • 単位が数字と同じ大きさ:「社」「%」まで大きいと、数字が埋もれる

多くの法人向けの LP は、カウントアップなしで最終値を大きく出すだけでも十分に伝わります。演出を足すのは、数字とキャプションが固まってからにしてください。

完成したカウントアップのセクションをコピーして使う方法

DesignLayer のコンポーネント一覧。左のカテゴリで用途ごとに絞り込める
DesignLayer のコンポーネント一覧。左のカテゴリで用途ごとに絞り込める

次の点まで揃えるには、細かな調整が続きます。

  • 数字の大きさ

  • 区切りの線

  • キャプションとの距離

  • 入場のタイミング

DesignLayer では、人が組んだモーション付きのセクションをブラウザでライブプレビューできます。https://design-layer.com/ja/components

気に入ったものをコピーし、数字とキャプションだけ差し替えてください。

ライブプレビューは無制限です。無料サンプル10個を無料アカウントで回数制限なくコピーでき、動画プロンプトは月1回です。Free では MCP に接続できません。カタログ全体は30日ビルドパス($19の1回払い、自動更新なし)、Lifetime Access(先着100名の$79の1回払い)、Plus(月額 $9、いつでも解約可)です。コピーしたあとも、数字の事実確認は必ず行ってください。

数字のカウントアップについてよくある質問

  • 必要ありません。この記事のコードのように、ブラウザに元からある機能だけで作れます。

    1つは、画面に入ったかを調べる Intersection Observer です。もう1つは、画面の書き換えに合わせて数字を更新する requestAnimationFrame です。

    すでに Motion を使っている場合は、useInView と animate を使うと短く書けます。

まとめ:Next.js で数字をカウントアップさせる手順

  • 数字のカウントアップは、画面に入ったときに1回だけ、1秒前後で止める

  • サーバーの HTML と読み上げには最終値を渡し、動きを減らす設定の人には動かさない

  • 数字の幅を揃え、カンマ区切りで表示して横揺れを防ぐ

  • 数字は3つまで。キャプションと出典で「何の数字か」を伝える

スクロールで要素を順に出す方法は https://design-layer.com/ja/articles/nextjs-scroll-reveal-guide にまとめています。

Motion の基本は https://design-layer.com/ja/articles/nextjs-framer-motion-guide で解説しています。

仕上がった数字のセクションから始めたい場合は、カタログで見比べてください。https://design-layer.com/ja/components

関連ガイド