この記事では、導入企業数や満足度などの実績数値を、画面に入ったタイミングで0から目標値へ動かすカウントアップを紹介します。プラグインやライブラリは使わず、素のJavaScriptだけで実装します。

🌐 デモを見る(GitHub Pages)

💻 ソースコード(GitHub)

この記事で分かること
  • Intersection Observer で画面に入った瞬間に数値を一度だけ動かす方法
  • requestAnimationFrame とイージングで「最初は速く・最後はゆっくり」着地させる方法
  • Intl.NumberFormat で桁区切り・小数・接尾辞(+ / %)を整える方法
  • HTMLに最終値を書いておき、JavaScriptが動かない環境でも数値を残す設計
  • 動きを減らす設定では即座に最終値を表示する配慮

このスニペットで作れるもの

実績やKPIのカードが画面に入ると、数値が0から目標値へ動き出すカウントアップです。導入企業数・満足度・累計ダウンロード・評価点などを、ページ内で印象的に見せたいときに使えます。

動作の特徴は次の通りです。

  • 画面に入った瞬間に、数値が0から目標値までカウントする
  • 最初は速く、最後はゆっくり減速して目標値へ着地する
  • 桁区切り(1,200)・接尾辞(+ / %)・小数(4.8)に対応する
  • 一度動いたら、上下にスクロールしても再カウントしない
  • JavaScriptが動かない環境でも最終値が表示され、レイアウトは壊れない
  • 動きを減らすOS設定が有効なときは、即座に最終値を表示する

HTMLの構造を見てみよう

数値カードをリストで並べた構造です。カウントさせたい数値には、監視対象を示すフック属性(data-count-up)と、目標値などを指定するデータ属性を付けます。

HTML
<ul class="c-count">
  <li class="c-count__item">
    <span
      class="c-count__value"
      data-count-up
      data-count-to="1200"
      data-count-suffix="+"
      >1,200+</span
    >
    <span class="c-count__label">導入企業</span>
  </li>

  <li class="c-count__item">
    <span
      class="c-count__value"
      data-count-up
      data-count-to="98"
      data-count-suffix="%"
      >98%</span
    >
    <span class="c-count__label">満足度</span>
  </li>

  <li class="c-count__item">
    <span
      class="c-count__value"
      data-count-up
      data-count-to="50000"
      data-count-duration="2600"
      >50,000</span
    >
    <span class="c-count__label">累計ダウンロード</span>
  </li>

  <li class="c-count__item">
    <span
      class="c-count__value"
      data-count-up
      data-count-to="4.8"
      data-count-decimals="1"
      >4.8</span
    >
    <span class="c-count__label">平均評価</span>
  </li>
</ul>
OPEN

ポイントは、数値本体のテキストに「整形済みの最終値」を最初から書いておくことです(例: 1,200+)。理由は後述します。

数値本体に付けるデータ属性

カウントの挙動は、すべてHTMLのデータ属性で指定します。属性を付けるだけで対象を増やせます。

  • data-count-up — 監視対象であることを示すフック属性
  • data-count-to — 目標値(必須)。0以上の有限数を指定する
  • data-count-duration — カウント時間(ミリ秒)。省略時は2000
  • data-count-suffix — 接尾辞(+ / % / 人 など)。省略可
  • data-count-decimals — 小数桁数。省略時は0
  • data-count-easinglinear で等速に切り替え。省略時は減速して着地する

このフック属性は、出現演出など他のスクロール演出で使う属性とは別系統です。そのため、同じページに複数の演出を同居させても干渉しません。

なぜHTMLに「最終値」を書いておくのか

JavaScriptが動く環境では、カウント開始前に表示を一旦0へ初期化します。つまり、0から動かす処理はJavaScriptが担います。

そこでHTMLには、整形済みの最終値をテキストで持たせておきます。こうしておけば、JavaScriptが無効・未対応の場合や、目標値が異常な場合でも、最終値がそのまま表示されます。数値(情報)が欠落しないための設計です。これがプログレッシブエンハンスメントの要点です。

スクロールで発火させる(Intersection Observer)

数値が画面に入ったのを検知してカウントを始めるために、Intersection Observer を使います。このスニペットのJSのうち、発火に関わる部分は次の通りです。

JavaScript
const observer = new IntersectionObserver(
  function (entries, obs) {
    entries.forEach(function (entry) {
      if (!entry.isIntersecting) return;

      const cfg = configMap.get(entry.target);
      if (cfg) start(entry.target, cfg);

      // ワンショット発火: 一度発火したら監視を解除して多重発火を防ぐ。
      obs.unobserve(entry.target);
    });
  },
  {
    threshold: 0.15,
    rootMargin: "0px 0px -10% 0px",
  }
);

画面に入ったら一度だけ動かす(ワンショット発火)

交差を検知したら、その要素のカウントを開始し、すぐ unobserve で監視を解除します。これにより、上下にスクロールしても再カウントせず、多重発火も防げます。一度動いたら終わり、というワンショットの動きです。

発火のタイミングは、観測オプションで調整します。threshold: 0.15 は要素の15%が画面に入った時点で発火する指定です。rootMargin: "0px 0px -10% 0px" は下端を10%内側に詰める指定で、要素がやや上に入ってから発火し、自然なタイミングになります。

数値を滑らかに動かす(requestAnimationFrame + イージング)

0から目標値まで数値を補間するには、requestAnimationFrame を使います。このスニペットのJSのうち、補間に関わる部分は次の通りです。

JavaScript
function animate(el, cfg) {
  // 所要時間 0(または極端に短い指定)の場合は補間せず最終値を表示。
  if (cfg.duration <= 0) {
    render(el, cfg.to, cfg);
    return;
  }

  const startTime = performance.now();

  function step(now) {
    // 経過割合 0〜1。1 を超えないようクランプし、最終フレームを確定させる。
    const elapsed = now - startTime;
    let progress = elapsed / cfg.duration;
    if (progress >= 1) progress = 1;

    const eased = cfg.linear ? progress : easeOutQuad(progress);
    const current = cfg.to * eased;

    render(el, current, cfg);

    // progress が 1 に達したら、最終値をそのまま描いた状態でループを止める。
    // これ以上 rAF を予約しないことでリークを残さない。
    if (progress < 1) {
      window.requestAnimationFrame(step);
    }
  }

  window.requestAnimationFrame(step);
}
OPEN

経過時間から進捗(0〜1)を求め、現在値=目標値×進捗を毎フレーム描き直します。

setIntervalではなくrequestAnimationFrameを使う理由

一定間隔のタイマー(setInterval)ではなく、画面の描画タイミングに同期して更新するのが requestAnimationFrame です。描画と同期するため滑らかで、カクつきにくくなります。さらに、タブが非アクティブなときは自動で間引かれるため、無駄な処理も減ります。

easeOutQuad で「最初は速く・最後はゆっくり」着地させる

進捗をそのまま使う(等速)と、機械的な動きに見えます。そこで進捗にイージングをかけ、減速しながら目標値へ着地させます。使っているのは easeOutQuad です。

JavaScript
function easeOutQuad(t) {
  return 1 - (1 - t) * (1 - t);
}

最初は速く、最後はゆっくり止まる曲線です。等速にしたいときは、データ属性 data-count-easing="linear" で切り替えられます。

目標値ぴったりで止める

最終フレームでは、進捗を強制的に1に確定させます。これで丸め誤差なく目標値ちょうどを描けます。その状態を描いたら、もう requestAnimationFrame を予約しません。フレームを残さないことで、リークを防ぎます。

桁区切り・接尾辞・小数を整える(Intl.NumberFormat)

カンマ区切りや小数桁の整形は、Intl.NumberFormat に任せます。このスニペットのJSのうち、整形に関わる部分は次の通りです。

JavaScript
const formatterCache = {};

function getFormatter(decimals) {
  if (!formatterCache[decimals]) {
    formatterCache[decimals] = new Intl.NumberFormat("en-US", {
      minimumFractionDigits: decimals,
      maximumFractionDigits: decimals,
    });
  }
  return formatterCache[decimals];
}

function render(el, value, cfg) {
  el.textContent = getFormatter(cfg.decimals).format(value) + cfg.suffix;
}

Intl.NumberFormat がカンマ区切りと小数桁を自動で整形します。その後ろに接尾辞(+ / %)を足して表示します。自分でカンマを足す必要はありません。小数桁ごとにフォーマッタが変わるため、一度作ったものはキャッシュして使い回します。

桁がカウント中に揺れないようにする

カウント中は数字が刻々と変わります。フォントによっては、数字ごとに幅が違って桁が左右にブレることがあります。これを防ぐのが、等幅数字の指定です。

CSS
.c-count__value {
  font-variant-numeric: tabular-nums;
}

font-variant-numeric: tabular-nums は、すべての数字の幅を揃える指定です。これでカウント中も桁がブレず、数値が安定して読めます。

動きを減らす設定への配慮(prefers-reduced-motion)

OSの「動きを減らす」設定が有効なときは、カウントせず、即座に最終値を表示します。

JavaScript
function start(el, cfg) {
  if (prefersReducedMotion) {
    render(el, cfg.to, cfg);
    return;
  }
  animate(el, cfg);
}

これは出現演出(フェードインなど)と同じ「即時最終状態」の扱いです。数値そのものは情報なので、動かさずに表示すれば、必要な情報は常に伝わります。

JSが動かない環境でどう見えるか(プログレッシブエンハンスメント)

ここでは、各環境で結果としてどう見えるかを確認します。

JavaScriptが無効・Intersection Observer 非対応・目標値が異常、といった場合は、初期化も監視も行いません。そのため、HTMLに書いた最終値がそのまま残ります。

JavaScript
const items = [];
targets.forEach(function (el) {
  const cfg = readConfig(el);
  if (!cfg) return; // フォールバック: 最終値のテキストをそのまま残す
  items.push({ el: el, cfg: cfg });
});

if (items.length === 0) return;

// JS が動く環境では、カウント開始前に一旦 0(整形済み)へ初期化する。
items.forEach(function (item) {
  render(item.el, 0, item.cfg);
});

// IntersectionObserver 非対応環境のフォールバック: 監視せず即カウント開始。
if (!("IntersectionObserver" in window)) {
  items.forEach(function (item) {
    start(item.el, item.cfg);
  });
  return;
}
OPEN

0への初期化はJavaScriptが動く環境だけで起きます。だから、JavaScriptが動かなければ最終値が残ります。Intersection Observer に未対応の環境では、監視せずに即カウントするフォールバックへ進みます。どの環境でも数値は必ず表示され、レイアウトが壊れることはありません。

コピペで使うための完成コード

ここまでの内容をまとめて、HTML / CSS / JS をフルで再掲します。HTMLは <head> のインラインスクリプトを含む全体です。このまま貼り付ければ動作する状態です。

HTML
<!DOCTYPE html>
<html lang="ja" class="no-js">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>カウントアップ</title>

    <!--
      JS が読み込まれる前に html の class を no-js → js へ書き換える。
      head 内のインラインスクリプトで実行することで、画面描画前に
      切り替わり、初期状態のチラつき(FOUC)を防ぐ。
      JS 無効環境・このスクリプトが届かない環境では no-js のままになり、
      CSS 側で数値を最初から最終値で表示する(情報の欠落を防ぐ)。
    -->
    <script>
      document.documentElement.classList.remove("no-js");
      document.documentElement.classList.add("js");
    </script>

    <link rel="stylesheet" href="./assets/css/style.css" />
  </head>
  <body>
    <main class="l-main">
      <section class="p-section">
        <h1 class="p-section__title">カウントアップ</h1>

        <p class="p-section__text">
          Intersection Observer でビューポートへの進入を検知し、
          <code>requestAnimationFrame</code> で 0 から目標値まで数値を補間する
          カウントアップです。イージング(easeOutQuad)で最初は速く、
          最後はゆっくり目標値へ着地します。各カウンターは一度発火したら監視を解除します。
          OS の「視差効果を減らす」設定が有効な場合は、カウントせず即時に最終値を表示します。
        </p>

        <!--
          data-count-up:       監視対象であることを示すフック属性。
          data-count-to:       目標値(必須)。0 以上の有限数を指定する。
          data-count-duration: カウント時間(ms)。省略時は 2000ms。
          data-count-suffix:   接尾辞(+ / % / 人 / 件 など)。省略可。
          data-count-decimals: 小数桁数。省略時は 0。
          data-count-easing:   "linear" で等速に切り替え可。省略時は easeOutQuad。

          各 .c-count__value のテキストには「整形済みの最終値」を書いておく。
          JS が動けば一旦 0 に初期化してカウントし、JS 無効・IO 非対応環境では
          この最終値がそのまま表示される(情報が欠落しない)。
        -->
        <ul class="c-count">
          <li class="c-count__item">
            <span
              class="c-count__value"
              data-count-up
              data-count-to="1200"
              data-count-suffix="+"
              >1,200+</span
            >
            <span class="c-count__label">導入企業</span>
          </li>

          <li class="c-count__item">
            <span
              class="c-count__value"
              data-count-up
              data-count-to="98"
              data-count-suffix="%"
              >98%</span
            >
            <span class="c-count__label">満足度</span>
          </li>

          <li class="c-count__item">
            <span
              class="c-count__value"
              data-count-up
              data-count-to="50000"
              data-count-duration="2600"
              >50,000</span
            >
            <span class="c-count__label">累計ダウンロード</span>
          </li>

          <li class="c-count__item">
            <span
              class="c-count__value"
              data-count-up
              data-count-to="4.8"
              data-count-decimals="1"
              >4.8</span
            >
            <span class="c-count__label">平均評価</span>
          </li>
        </ul>
      </section>
    </main>

    <script src="./assets/js/script.js" defer></script>
  </body>
</html>
OPEN
CSS
/* ================================================
   カスタムプロパティ
   配色・余白をここで一元管理する。
   ================================================ */
:root {
  --count-color-text:        #2b2b2b;
  --count-color-text-muted:  #555;
  --count-color-border:      #e0e0e0;
  --count-color-bg:          #ffffff;
  --count-color-accent:      #3ba9e0;

  --count-radius:            8px;
  --count-card-padding-y:    28px;
  --count-card-padding-x:    24px;
}

/* ================================================
   ベースリセット
   ================================================ */
*,
*::before,
*::after {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
    "Hiragino Sans", "Noto Sans JP", sans-serif;
  color: var(--count-color-text);
  background-color: #fafafa;
  line-height: 1.7;
}

code {
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
  font-size: 0.92em;
  padding: 0.1em 0.35em;
  background-color: #eef0f3;
  border-radius: 4px;
}

/* ================================================
   l-main / p-section: ページのレイアウト
   ================================================ */
.l-main {
  max-width: 720px;
  margin-inline: auto;
  padding: 40px 20px;
}

.p-section__title {
  font-size: 22px;
  font-weight: 700;
  margin: 0 0 16px;
}

.p-section__text {
  margin: 0 0 24px;
  color: var(--count-color-text-muted);
}

@media (min-width: 768px) {
  .p-section__title {
    font-size: 28px;
  }
}

/* ================================================
   c-count: 実績/KPI をカード状に並べるリスト
   モバイル幅では 1 列、768px 以上で 2 列。
   ================================================ */
.c-count {
  display: grid;
  grid-template-columns: 1fr;
  gap: 20px;
  margin: 0;
  padding: 0;
  list-style: none;
}

@media (min-width: 768px) {
  .c-count {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* ================================================
   c-count__item: 1 つの数値カード
   ================================================ */
.c-count__item {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: var(--count-card-padding-y) var(--count-card-padding-x);
  text-align: center;
  background-color: var(--count-color-bg);
  border: 1px solid var(--count-color-border);
  border-top: 4px solid var(--count-color-accent);
  border-radius: var(--count-radius);
}

/* ================================================
   c-count__value: カウント表示される数値本体
   等幅数字(tabular-nums)でカウント中の桁の
   横ブレを抑え、数値が安定して読めるようにする。
   ================================================ */
.c-count__value {
  font-size: 40px;
  font-weight: 700;
  line-height: 1.1;
  color: var(--count-color-accent);
  font-variant-numeric: tabular-nums;
}

@media (min-width: 768px) {
  .c-count__value {
    font-size: 48px;
  }
}

.c-count__label {
  font-size: 14px;
  color: var(--count-color-text-muted);
}
OPEN
JavaScript
/**
 * カウントアップ(スクロール発火の数値アニメーション)
 * Intersection Observer でビューポート進入を検知し、requestAnimationFrame で
 * 0 から目標値まで数値を補間して表示する。
 *
 * 設計方針:
 * - 監視対象は data-count-up 属性を持つ要素。HTML 側で属性を付けるだけで
 *   対象を増やせる。フック名は出現演出系(data-fade-in 等)と衝突しない
 *   独立命名にしてある。
 * - ワンショット発火: 一度交差した要素は unobserve して監視を解除する。
 *   これにより上下スクロールでの再カウントと多重発火を防ぐ。
 * - イージングは easeOutQuad を既定とし、最初は速く最後はゆっくり目標値へ
 *   着地させる。data-count-easing="linear" で等速にも切り替えられる。
 * - rAF は目標到達(経過時間が所要時間に達した瞬間)でループを止める。
 *   最終フレームでは進捗を強制的に 1 にして、丸め誤差なく目標値ぴったりへ
 *   着地させてから停止する(rAF リークを残さない)。
 *
 * プログレッシブエンハンスメント / フォールバック:
 * - HTML の各数値には「整形済みの最終値」をテキストで書いてある。
 *   JS が動く環境ではカウント開始前に一旦 0 へ初期化してから補間する。
 * - JS 無効・IntersectionObserver 非対応・目標値が異常などの場合は、
 *   初期化や監視を行わず HTML の最終値をそのまま残す(情報が欠落しない)。
 *
 * アクセシビリティ:
 * - prefers-reduced-motion: reduce のときはカウントアニメーションを行わず、
 *   即時に最終値を表示する(出現演出系の「即時最終状態」と同じ性質)。
 */

(function () {
  "use strict";

  const targets = document.querySelectorAll("[data-count-up]");
  if (targets.length === 0) return;

  // 既定値。data 属性が省略・異常なときに使う。
  const DEFAULT_DURATION = 2000; // ms

  // 桁区切り+小数桁を整形するためのフォーマッタをロケール固定で生成する。
  // 小数桁ごとにフォーマッタが変わるのでキャッシュして使い回す。
  const formatterCache = {};

  /**
   * 指定小数桁の NumberFormat を返す(ロケール固定 + 桁区切りあり)。
   * @param {number} decimals - 小数桁数(0 以上の整数)
   * @returns {Intl.NumberFormat}
   */
  function getFormatter(decimals) {
    if (!formatterCache[decimals]) {
      formatterCache[decimals] = new Intl.NumberFormat("en-US", {
        minimumFractionDigits: decimals,
        maximumFractionDigits: decimals,
      });
    }
    return formatterCache[decimals];
  }

  /**
   * easeOutQuad: 最初は速く、最後はゆっくり減速して止まる。
   * @param {number} t - 進捗 0〜1
   * @returns {number} イージング適用後の進捗 0〜1
   */
  function easeOutQuad(t) {
    return 1 - (1 - t) * (1 - t);
  }

  /**
   * 1 要素のカウント設定を data 属性から読み取り、検証して返す。
   * 目標値が異常(NaN・負値・属性なし)なら null を返し、呼び出し側で
   * HTML の最終値を温存する。
   * @param {HTMLElement} el
   * @returns {{to:number, duration:number, suffix:string, decimals:number, linear:boolean}|null}
   */
  function readConfig(el) {
    const to = Number(el.dataset.countTo);
    // 目標値の検証: 有限かつ 0 以上のみ受け付ける(既定の検証基準)。
    if (!(Number.isFinite(to) && to >= 0)) return null;

    // 所要時間: 有限かつ 0 以上なら採用、それ以外は既定値へフォールバック。
    const rawDuration = Number(el.dataset.countDuration);
    const duration =
      Number.isFinite(rawDuration) && rawDuration >= 0
        ? rawDuration
        : DEFAULT_DURATION;

    // 小数桁: 有限かつ 0 以上の整数なら採用、それ以外は 0。
    const rawDecimals = Number(el.dataset.countDecimals);
    const decimals =
      Number.isFinite(rawDecimals) && rawDecimals >= 0
        ? Math.floor(rawDecimals)
        : 0;

    const suffix = el.dataset.countSuffix || "";
    const linear = el.dataset.countEasing === "linear";

    return { to: to, duration: duration, suffix: suffix, decimals: decimals, linear: linear };
  }

  /**
   * 数値を整形して接尾辞を付け、要素へ書き込む。
   * @param {HTMLElement} el
   * @param {number} value
   * @param {{suffix:string, decimals:number}} cfg
   */
  function render(el, value, cfg) {
    el.textContent = getFormatter(cfg.decimals).format(value) + cfg.suffix;
  }

  /**
   * 0 → 目標値のカウントアニメーションを実行する。
   * 所要時間が 0 のときは rAF を回さず即座に最終値を表示する。
   * @param {HTMLElement} el
   * @param {object} cfg - readConfig の戻り値
   */
  function animate(el, cfg) {
    // 所要時間 0(または極端に短い指定)の場合は補間せず最終値を表示。
    if (cfg.duration <= 0) {
      render(el, cfg.to, cfg);
      return;
    }

    const startTime = performance.now();

    function step(now) {
      // 経過割合 0〜1。1 を超えないようクランプし、最終フレームを確定させる。
      const elapsed = now - startTime;
      let progress = elapsed / cfg.duration;
      if (progress >= 1) progress = 1;

      const eased = cfg.linear ? progress : easeOutQuad(progress);
      const current = cfg.to * eased;

      render(el, current, cfg);

      // progress が 1 に達したら、最終値(cfg.to)をそのまま描いた状態で
      // ループを止める。これ以上 rAF を予約しないことでリークを残さない。
      if (progress < 1) {
        window.requestAnimationFrame(step);
      }
    }

    window.requestAnimationFrame(step);
  }

  // OS の「視差効果を減らす」設定。reduce のときはカウントせず即最終値。
  const prefersReducedMotion =
    typeof window.matchMedia === "function" &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  /**
   * カウント開始のトリガ。reduced-motion 時は補間せず最終値を即表示する。
   * @param {HTMLElement} el
   * @param {object} cfg
   */
  function start(el, cfg) {
    if (prefersReducedMotion) {
      render(el, cfg.to, cfg);
      return;
    }
    animate(el, cfg);
  }

  // 各要素の設定を事前に検証し、有効なものだけ初期化・監視する。
  // 異常値(目標値が NaN・負値・属性なし)の要素は HTML の最終値を温存する。
  const items = [];
  targets.forEach(function (el) {
    const cfg = readConfig(el);
    if (!cfg) return; // フォールバック: 最終値のテキストをそのまま残す
    items.push({ el: el, cfg: cfg });
  });

  if (items.length === 0) return;

  // JS が動く環境では、カウント開始前に一旦 0(整形済み)へ初期化する。
  // ここで初めて表示が 0 になるため、JS 無効時は最終値が残る(PE)。
  items.forEach(function (item) {
    render(item.el, 0, item.cfg);
  });

  // IntersectionObserver 非対応環境のフォールバック:
  // 監視せず、全要素を即時にカウント(reduce 時は最終値)開始する。
  if (!("IntersectionObserver" in window)) {
    items.forEach(function (item) {
      start(item.el, item.cfg);
    });
    return;
  }

  // 要素から設定を引けるよう WeakMap で対応付ける。
  const configMap = new WeakMap();
  items.forEach(function (item) {
    configMap.set(item.el, item.cfg);
  });

  /**
   * 観測オプション
   * - threshold: 0.15  要素の 15% が入った時点で発火。
   * - rootMargin: "0px 0px -10% 0px"  下端を 10% 内側に詰めて、
   *     やや上に入ってから発火させ自然なタイミングにする。
   */
  const observer = new IntersectionObserver(
    function (entries, obs) {
      entries.forEach(function (entry) {
        if (!entry.isIntersecting) return;

        const cfg = configMap.get(entry.target);
        if (cfg) start(entry.target, cfg);

        // ワンショット発火: 一度発火したら監視を解除して多重発火を防ぐ。
        obs.unobserve(entry.target);
      });
    },
    {
      threshold: 0.15,
      rootMargin: "0px 0px -10% 0px",
    }
  );

  items.forEach(function (item) {
    observer.observe(item.el);
  });
})();
OPEN

カスタマイズしたい場面でよく触るのは次のポイントです。

  • 目標値: data-count-to(必須)
  • カウント時間: data-count-duration(ミリ秒・省略時2000)
  • 接尾辞(+ / % など): data-count-suffix
  • 小数桁: data-count-decimals
  • 等速にする: data-count-easing="linear"
  • 数値の色・カードの装飾: --count-color-accent ほかカスタムプロパティ

よくある質問

カウントアップ用のプラグインやライブラリは必要ですか?

必要ありません。このスニペットは素のJavaScriptだけで動きます。Intersection Observer で画面への進入を検知し、requestAnimationFrame で数値を0から目標値まで補間するため、外部ライブラリの読み込みは不要です。

桁区切りや「+」「%」、小数点はどう出しますか?

すべてHTMLのデータ属性で指定できます。data-count-to に数値を、data-count-suffix に「+」や「%」を、data-count-decimals に桁数を書くだけです。カンマ区切りは Intl.NumberFormat が自動で付けるため、自分でカンマを足す必要はありません。

JavaScriptが動かない環境では数値が消えてしまいますか?

消えません。HTMLの各数値には整形済みの最終値を最初から書いてあり、JavaScriptが動く環境だけ一旦0に戻してカウントします。そのためJavaScriptが無効・未対応の環境では最終値がそのまま表示され、情報が欠落することもレイアウトが崩れることもありません。

まとめ

Intersection ObserverrequestAnimationFrame で、スクロール発火のカウントアップを実装しました。要点は次の通りです。

  • Intersection Observer で、画面に入った瞬間に一度だけ発火する(ワンショット)
  • requestAnimationFrame + easeOutQuad で、最初は速く最後はゆっくり目標値へ着地する
  • Intl.NumberFormat で桁区切り・小数を自動整形し、接尾辞を付ける
  • HTMLに最終値を書いておくので、JavaScriptが動かなくても情報は欠落しない
  • 動きを減らす設定では、カウントせず即座に最終値を表示する

まずはコピペして動かし、データ属性で目標値や接尾辞・小数桁を変えられること、JavaScriptなしでも最終値が出ることを確認してみてください。

関連記事