この記事では、導入企業数や満足度などの実績数値を、画面に入ったタイミングで0から目標値へ動かすカウントアップを紹介します。プラグインやライブラリは使わず、素のJavaScriptだけで実装します。
Intersection Observerで画面に入った瞬間に数値を一度だけ動かす方法requestAnimationFrameとイージングで「最初は速く・最後はゆっくり」着地させる方法Intl.NumberFormatで桁区切り・小数・接尾辞(+ / %)を整える方法- HTMLに最終値を書いておき、JavaScriptが動かない環境でも数値を残す設計
- 動きを減らす設定では即座に最終値を表示する配慮
このスニペットで作れるもの
実績やKPIのカードが画面に入ると、数値が0から目標値へ動き出すカウントアップです。導入企業数・満足度・累計ダウンロード・評価点などを、ページ内で印象的に見せたいときに使えます。
動作の特徴は次の通りです。
- 画面に入った瞬間に、数値が0から目標値までカウントする
- 最初は速く、最後はゆっくり減速して目標値へ着地する
- 桁区切り(1,200)・接尾辞(+ / %)・小数(4.8)に対応する
- 一度動いたら、上下にスクロールしても再カウントしない
- JavaScriptが動かない環境でも最終値が表示され、レイアウトは壊れない
- 動きを減らすOS設定が有効なときは、即座に最終値を表示する
HTMLの構造を見てみよう
数値カードをリストで並べた構造です。カウントさせたい数値には、監視対象を示すフック属性(data-count-up)と、目標値などを指定するデータ属性を付けます。
<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>ポイントは、数値本体のテキストに「整形済みの最終値」を最初から書いておくことです(例: 1,200+)。理由は後述します。
数値本体に付けるデータ属性
カウントの挙動は、すべてHTMLのデータ属性で指定します。属性を付けるだけで対象を増やせます。
data-count-up— 監視対象であることを示すフック属性data-count-to— 目標値(必須)。0以上の有限数を指定するdata-count-duration— カウント時間(ミリ秒)。省略時は2000data-count-suffix— 接尾辞(+ / % / 人 など)。省略可data-count-decimals— 小数桁数。省略時は0data-count-easing—linearで等速に切り替え。省略時は減速して着地する
このフック属性は、出現演出など他のスクロール演出で使う属性とは別系統です。そのため、同じページに複数の演出を同居させても干渉しません。
なぜHTMLに「最終値」を書いておくのか
JavaScriptが動く環境では、カウント開始前に表示を一旦0へ初期化します。つまり、0から動かす処理はJavaScriptが担います。
そこでHTMLには、整形済みの最終値をテキストで持たせておきます。こうしておけば、JavaScriptが無効・未対応の場合や、目標値が異常な場合でも、最終値がそのまま表示されます。数値(情報)が欠落しないための設計です。これがプログレッシブエンハンスメントの要点です。
スクロールで発火させる(Intersection Observer)
数値が画面に入ったのを検知してカウントを始めるために、Intersection Observer を使います。このスニペットのJSのうち、発火に関わる部分は次の通りです。
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のうち、補間に関わる部分は次の通りです。
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);
}経過時間から進捗(0〜1)を求め、現在値=目標値×進捗を毎フレーム描き直します。
setIntervalではなくrequestAnimationFrameを使う理由
一定間隔のタイマー(setInterval)ではなく、画面の描画タイミングに同期して更新するのが requestAnimationFrame です。描画と同期するため滑らかで、カクつきにくくなります。さらに、タブが非アクティブなときは自動で間引かれるため、無駄な処理も減ります。
easeOutQuad で「最初は速く・最後はゆっくり」着地させる
進捗をそのまま使う(等速)と、機械的な動きに見えます。そこで進捗にイージングをかけ、減速しながら目標値へ着地させます。使っているのは easeOutQuad です。
function easeOutQuad(t) {
return 1 - (1 - t) * (1 - t);
}最初は速く、最後はゆっくり止まる曲線です。等速にしたいときは、データ属性 data-count-easing="linear" で切り替えられます。
目標値ぴったりで止める
最終フレームでは、進捗を強制的に1に確定させます。これで丸め誤差なく目標値ちょうどを描けます。その状態を描いたら、もう requestAnimationFrame を予約しません。フレームを残さないことで、リークを防ぎます。
桁区切り・接尾辞・小数を整える(Intl.NumberFormat)
カンマ区切りや小数桁の整形は、Intl.NumberFormat に任せます。このスニペットのJSのうち、整形に関わる部分は次の通りです。
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 がカンマ区切りと小数桁を自動で整形します。その後ろに接尾辞(+ / %)を足して表示します。自分でカンマを足す必要はありません。小数桁ごとにフォーマッタが変わるため、一度作ったものはキャッシュして使い回します。
桁がカウント中に揺れないようにする
カウント中は数字が刻々と変わります。フォントによっては、数字ごとに幅が違って桁が左右にブレることがあります。これを防ぐのが、等幅数字の指定です。
.c-count__value {
font-variant-numeric: tabular-nums;
}font-variant-numeric: tabular-nums は、すべての数字の幅を揃える指定です。これでカウント中も桁がブレず、数値が安定して読めます。
動きを減らす設定への配慮(prefers-reduced-motion)
OSの「動きを減らす」設定が有効なときは、カウントせず、即座に最終値を表示します。
function start(el, cfg) {
if (prefersReducedMotion) {
render(el, cfg.to, cfg);
return;
}
animate(el, cfg);
}これは出現演出(フェードインなど)と同じ「即時最終状態」の扱いです。数値そのものは情報なので、動かさずに表示すれば、必要な情報は常に伝わります。
JSが動かない環境でどう見えるか(プログレッシブエンハンスメント)
ここでは、各環境で結果としてどう見えるかを確認します。
JavaScriptが無効・Intersection Observer 非対応・目標値が異常、といった場合は、初期化も監視も行いません。そのため、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(整形済み)へ初期化する。
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;
}0への初期化はJavaScriptが動く環境だけで起きます。だから、JavaScriptが動かなければ最終値が残ります。Intersection Observer に未対応の環境では、監視せずに即カウントするフォールバックへ進みます。どの環境でも数値は必ず表示され、レイアウトが壊れることはありません。
コピペで使うための完成コード
ここまでの内容をまとめて、HTML / CSS / JS をフルで再掲します。HTMLは <head> のインラインスクリプトを含む全体です。このまま貼り付ければ動作する状態です。
<!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>/* ================================================
カスタムプロパティ
配色・余白をここで一元管理する。
================================================ */
: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);
}/**
* カウントアップ(スクロール発火の数値アニメーション)
* 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);
});
})();カスタマイズしたい場面でよく触るのは次のポイントです。
- 目標値:
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 Observer と requestAnimationFrame で、スクロール発火のカウントアップを実装しました。要点は次の通りです。
Intersection Observerで、画面に入った瞬間に一度だけ発火する(ワンショット)requestAnimationFrame+ easeOutQuad で、最初は速く最後はゆっくり目標値へ着地するIntl.NumberFormatで桁区切り・小数を自動整形し、接尾辞を付ける- HTMLに最終値を書いておくので、JavaScriptが動かなくても情報は欠落しない
- 動きを減らす設定では、カウントせず即座に最終値を表示する
まずはコピペして動かし、データ属性で目標値や接尾辞・小数桁を変えられること、JavaScriptなしでも最終値が出ることを確認してみてください。
