使用 IntersectionObserver 實作數字滾動動畫效果

前言

在網頁中,常見的數字滾動動畫能有效提升視覺吸引力,尤其用於統計數據或關鍵指標的呈現。這段程式碼示範如何結合 IntersectionObserver 與計時器,實現當數字元素滾動進入視窗時,從初始值平滑遞增到目標數字的動畫效果。適合有基礎 JavaScript 知識,並希望理解如何優化滾動觸發動畫的工程師或自學者。

使用 IntersectionObserver 監控元素可見性

為避免動畫在頁面初始就執行,程式利用 IntersectionObserver 監控所有帶有 .number 類別的元素。當元素至少有 30% 進入視窗時,觸發動畫函式。

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        animateCounter(entry.target);
      }
    });
  },
  { threshold: 0.3 }
);

counters.forEach(counter => {
  observer.observe(counter);
});

這裡的 threshold 設為 0.3,代表元素可見度達 30% 即觸發動畫,這個數值可以根據需求調整,達到更精準的觸發時機。

數字動畫的實作細節

動畫函式 animateCounter 主要負責數字從初始值(此處為 1)平滑遞增到目標數字。實作重點如下:

避免重複執行

利用元素的 dataset.animated 屬性標記,確保同一元素不會重複觸發動畫,避免動畫重疊或閃爍。

if (counter.dataset.animated) return;
counter.dataset.animated = "true";

處理數字與附加的 <sup> 標籤

有些數字後面可能會帶有 <sup> 標籤(例如加號「+」),程式先將其分離並保留,動畫過程只針對純數字進行計算,最後再將 <sup> 加回去。

const sup = counter.querySelector("sup");
const supHTML = sup ? sup.outerHTML : "";
const rawText = counter.childNodes[0].nodeValue.trim();
const target = parseFloat(rawText);

動畫邏輯

  • 設定動畫總時長為 1500 毫秒,幀率為 30 fps。
  • 使用 setInterval 逐幀更新數字。
  • 根據進度比例計算當前數字,並判斷是否為小數,決定顯示格式。
  • 動畫結束時,強制將數字顯示為原始目標值,避免浮點數誤差。
const duration = 1500;
const frameRate = 30;
const totalFrames = Math.round(duration / (1000 / frameRate));
let frame = 0;

const timer = setInterval(() => {
  frame++;
  const progress = frame / totalFrames;
  const current = start + (target - start) * progress;
  counter.innerHTML = (isDecimal ? current.toFixed(1) : Math.floor(current)) + supHTML;
  if (frame >= totalFrames) {
    clearInterval(timer);
    counter.innerHTML = rawText + supHTML;
  }
}, 1000 / frameRate);

實際應用與優化方向

  • 此動畫適合用於展示統計數字、銷售數據、會員人數等 KPI。
  • 可依需求調整動畫時長與幀率,達到不同的視覺效果。
  • 若數字範圍極大,建議調整起始值或使用指數型動畫增長,提升動畫流暢度。
  • 可結合 CSS 動畫或其他視覺效果,增強整體互動體驗。

常見問題與注意事項

  • 重複觸發動畫:未使用 dataset.animated 標記,可能導致動畫重複執行。
  • 浮點數誤差:動畫過程中使用浮點數計算,最後強制顯示原始文字可避免誤差。
  • IntersectionObserver 支援度:大部分現代瀏覽器支援,舊版瀏覽器需考慮 polyfill。

完整程式碼

document.addEventListener("DOMContentLoaded", function () {

    const counters = document.querySelectorAll(".number");

    const animateCounter = (counter) => {

        // 避免重複執行
        if (counter.dataset.animated) return;
        counter.dataset.animated = "true";

        // 取出 <sup>+</sup>
        const sup = counter.querySelector("sup");
        const supHTML = sup ? sup.outerHTML : "";

        // 取純數字(忽略 sup)
        const rawText = counter.childNodes[0].nodeValue.trim();
        const target = parseFloat(rawText);
        const isDecimal = rawText.includes(".");

        let start = 1;
        const duration = 1500;
        const frameRate = 30;
        const totalFrames = Math.round(duration / (1000 / frameRate));
        let frame = 0;

        const timer = setInterval(() => {
            frame++;

            const progress = frame / totalFrames;
            const current = start + (target - start) * progress;

            counter.innerHTML = (isDecimal ? current.toFixed(1) : Math.floor(current)) + supHTML;

            if (frame >= totalFrames) {
                clearInterval(timer);
                counter.innerHTML = rawText + supHTML;
            }
        }, 1000 / frameRate);
    };

    // IntersectionObserver:滾動到可見才啟動
    const observer = new IntersectionObserver(
        (entries) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    animateCounter(entry.target);
                }
            });
        },
        { threshold: 0.3 } // 可見 30% 即觸發,可調整
    );

    counters.forEach(counter => {
        observer.observe(counter);
    });

});