Jak dodać animowane licznik statystyk

dowiedz się

Animowany licznik statystyk potrafi w kilka sekund nadać dynamiki stronie, podkreślić sukcesy i skierować wzrok użytkownika na kluczowe dane. W tym przewodniku krok po kroku zbudujesz gotowy komponent licznika: od planowania i doboru danych, przez czysty HTML/CSS/JS, po integrację z edytorami i systemami CMS. Nauczysz się uruchamiać animację w momencie pojawienia się w widoku, dbać o dostępność i wydajność, a także o poprawne formatowanie oraz realne scenariusze wdrożeniowe.

Plan i wymagania funkcjonalne

Co liczysz i po co?

Zacznij od celu: licznik ma wyraźnie wspierać treść. Określ, czy pokazujesz sumy (np. liczba klientów), wartości przyrostowe (np. wzrost procentowy), czy wyniki czasu rzeczywistego (np. aktywni użytkownicy). Zastanów się nad zakresem: małe liczby do setek tysięcy są łatwe do animowania; przy ogromnych wartościach lepiej postawić na skrót (1,2M) lub wykres słupkowy. Zaprojektuj także etykietę (np. “Zadowolonych klientów”), aby użytkownik rozumiał, co reprezentuje cyfra.

Jak ma działać animacja?

  • Wyzwalacz: przewinięcie do sekcji (rekomendowany), wejście w viewport (automatycznie), kliknięcie (manualnie) lub po zakończeniu innej animacji.
  • Czas trwania: zwykle 0,8–2 s dla liczb do kilkudziesięciu tysięcy; dłuższe sekwencje rozbijaj na etapy, aby zachować czytelność.
  • Przebieg (easing): liniowy jest najczytelniejszy; jeśli chcesz delikatny efekt, użyj easeOut.
  • Zakres: od 0 do wartości docelowej lub od bieżącej wartości do nowej (gdy aktualizujesz licznik dynamicznie).

Formaty i lokalizacja

W zależności od języka i kraju separatory tysięcy i ułamków mogą się różnić. W polskim najczęściej używamy spacji niełamliwej 1 234 567 oraz przecinka 12,5. Zaplanuj funkcję formatującą liczby z separacją tysięcy, skrótami (K, M) i jednostkami (np. “+”, “%”), a także uwzględnij waluty. Dodatkowo pomyśl o responsywnośći: na węższych ekranach skróty (1,5K) mogą być czytelniejsze niż pełne wartości.

Dostępność i preferencje użytkownika

Nie każdy użytkownik lubi animacje. Sprawdź media query prefers-reduced-motion i oferuj statyczny odczyt liczby, jeżeli użytkownik wyraźnie zaznaczył taką preferencję. Oznacz licznik dla czytników ekranu (np. aria-live=”polite”), aby zmiana wartości została zakomunikowana. Używaj etykiet (aria-label), nie polegaj tylko na kontekście wizualnym. Dobrą praktyką jest także dostarczenie alternatywnego opisu w treści.

Ograniczenia techniczne i wydajność

Planowanie pomaga uniknąć popularnych pułapek: zbyt wielu liczników na stronie, powielonych obserwatorów przewijania i nieszczelnych timerów. Postaw na IntersectionObserver i requestAnimationFrame; unikaj setInterval dla animacji liczbowych. Pamiętaj o jednorazowym wyzwalaniu, a podczas nawigacji SPA o czyszczeniu i ponownej inicjalizacji.

Implementacja w czystym HTML, CSS i JS

Struktura HTML z atrybutami danych

Użyj prostego markupu z atrybutami data- dla pełnej kontroli konfiguracji każdego licznika. Dzięki temu możesz dodać wiele liczników bez modyfikacji skryptu.

<section class="stats">
  <div class="stat">
    <div class="stat__value"
         data-target="12500"
         data-duration="1400"
         data-decimals="0"
         data-prefix=""
         data-suffix="+"
         data-easing="easeOut"
         aria-live="polite"
         aria-label="Liczba zrealizowanych projektów">0</div>
    <div class="stat__label">Zrealizowanych projektów</div>
  </div>

  <div class="stat">
    <div class="stat__value"
         data-target="98.7"
         data-duration="1200"
         data-decimals="1"
         data-prefix=""
         data-suffix="%"
         data-easing="linear"
         aria-live="polite"
         aria-label="Poziom satysfakcji klientów">0</div>
    <div class="stat__label">Satysfakcja klientów</div>
  </div>
</section>

Podstawowe style i przygotowanie do animacji

Stwórz czytelny kontrast i stabilny layout. Zadbaj o tablicowanie cyfr (font-variant-numeric: tabular-nums), aby szerokość znaków nie “skakała”.

.stats {
  display: grid;
  gap: 24px;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.stat__value {
  font-size: clamp(28px, 6vw, 56px);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  line-height: 1.1;
}
.stat__label {
  opacity: 0.8;
}

Funkcja formatowania liczb

Uniwersalny formater pozwala stosować różne reguły i skróty. Poniżej przykład z separacją tysięcy spacją niełamliwą, opcjonalnym skracaniem i dowolnym prefiksem/sufiksem.

function formatNumber(value, decimals = 0, short = false) {
  const abs = Math.abs(value);
  let unit = '';
  let num = value;

  if (short) {
    if (abs >= 1e9) { num = value / 1e9; unit = 'B'; }
    else if (abs >= 1e6) { num = value / 1e6; unit = 'M'; }
    else if (abs >= 1e3) { num = value / 1e3; unit = 'K'; }
  }

  const fixed = num.toFixed(decimals);
  const [intPart, decPart] = fixed.split('.');
  const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u00A0'); // spacja niełamliwa
  return decPart ? `${grouped},${decPart}${unit}` : `${grouped}${unit}`;
}

Animacja z requestAnimationFrame

Kluczem do płynnej i energooszczędnej animacji jest requestAnimationFrame. Dzięki niemu licznik będzie aktualizowany synchronicznie z odświeżaniem przeglądarki.

const easings = {
  linear: t => t,
  easeOut: t => 1 - Math.pow(1 - t, 3),
};

function animateCounter(el) {
  const target = parseFloat(el.dataset.target);
  const duration = parseInt(el.dataset.duration || 1200, 10);
  const decimals = parseInt(el.dataset.decimals || 0, 10);
  const prefix = el.dataset.prefix || '';
  const suffix = el.dataset.suffix || '';
  const easingName = el.dataset.easing || 'linear';
  const short = el.dataset.short === 'true';
  const ease = easings[easingName] || easings.linear;

  const preferReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (preferReduced) {
    el.textContent = `${prefix}${formatNumber(target, decimals, short)}${suffix}`;
    return Promise.resolve();
  }

  let start = null;
  const from = parseFloat(el.textContent.replace(/\s/g, '').replace(',', '.')) || 0;

  return new Promise(resolve => {
    function frame(ts) {
      if (!start) start = ts;
      const progress = Math.min((ts - start) / duration, 1);
      const current = from + (target - from) * ease(progress);
      el.textContent = `${prefix}${formatNumber(current, decimals, short)}${suffix}`;

      if (progress < 1) {
        requestAnimationFrame(frame);
      } else {
        el.textContent = `${prefix}${formatNumber(target, decimals, short)}${suffix}`;
        resolve();
      }
    }
    requestAnimationFrame(frame);
  });
}

Wyzwalanie animacji po wejściu w widok

Najwygodniej użyć IntersectionObserver. Zapewnia on niskie koszty działania i prostą logikę “raz uruchom, odłącz”.

function initCountersOnce(root = document) {
  const nodes = Array.from(root.querySelectorAll('.stat__value'));
  if (!('IntersectionObserver' in window)) {
    // Fallback: jeśli brak obsługi, uruchom od razu
    nodes.forEach(animateCounter);
    return;
  }

  const io = new IntersectionObserver((entries, obs) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        animateCounter(entry.target).then(() => {
          // Jednorazowo
          obs.unobserve(entry.target);
        });
      }
    });
  }, { threshold: 0.35 });

  nodes.forEach(n => io.observe(n));
}

document.addEventListener('DOMContentLoaded', () => {
  initCountersOnce(document);
});

Obsługa wielu liczników i ponowne odtwarzanie

Jeśli chcesz umożliwić ponowne odtworzenie (np. w karuzeli, zakładkach), dodaj klasę kontrolującą stan i reset.

function resetCounter(el) {
  el.textContent = '0';
}

function reinitCounters(scope) {
  const nodes = scope.querySelectorAll('.stat__value');
  nodes.forEach(resetCounter);
  initCountersOnce(scope);
}

Uwaga: Jeżeli używasz SPA, wywołuj reinitCounters po zmianie widoku. W przeciwnym razie animacje nie ruszą, bo DOM został wymieniony bez pełnego przeładowania.

Aktualizacja liczb z API

W scenariuszu dynamicznym pobierz dane, zaktualizuj atrybut data-target, a następnie odpal animację. Pamiętaj o try/catch i sensownych limitach wartości.

async function updateFromAPI(el, url) {
  try {
    const res = await fetch(url);
    const data = await res.json();
    const value = Number(data.value);
    if (!Number.isFinite(value)) return;

    el.dataset.target = String(value);
    animateCounter(el);
  } catch (e) {
    console.warn('Błąd aktualizacji licznika', e);
  }
}

Wariant z biblioteką CountUp.js

Instalacja i podstawy

Jeżeli wolisz gotowe rozwiązanie, użyj biblioteki CountUp.js. Daje opcje formatowania, easing, separatory i restart. Instalacja:

  • CDN: <script src=”https://cdn.jsdelivr.net/npm/countup.js@2.8.0/dist/countUp.umd.js”></script>
  • NPM: npm i countup.js

Inicjalizacja z opcjami formatowania

// CDN: CountUp jest dostępny jako window.CountUp
function initCountUpOnce() {
  const nodes = document.querySelectorAll('.stat__value');
  const io = new IntersectionObserver((entries, obs) => {
    entries.forEach(entry => {
      if (!entry.isIntersecting) return;

      const el = entry.target;
      const target = parseFloat(el.dataset.target);
      const duration = (parseInt(el.dataset.duration || 1200, 10) / 1000);
      const decimals = parseInt(el.dataset.decimals || 0, 10);
      const prefix = el.dataset.prefix || '';
      const suffix = el.dataset.suffix || '';

      const preferReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

      const opts = {
        startVal: 0,
        duration,
        decimalPlaces: decimals,
        useEasing: !preferReduced,
        separator: '\u00A0', // spacja niełamliwa
        decimal: ',',
        prefix,
        suffix
      };

      const cu = new window.CountUp.CountUp(el, target, opts);
      if (!cu.error) {
        cu.start(() => obs.unobserve(el));
      } else {
        console.error(cu.error);
        el.textContent = `${prefix}${target}${suffix}`;
        obs.unobserve(el);
      }
    });
  }, { threshold: 0.35 });

  nodes.forEach(n => io.observe(n));
}

document.addEventListener('DOMContentLoaded', initCountUpOnce);

Biblioteka sprawdza się, gdy chcesz szybko wdrożyć licznik z dopracowanym formatowaniem i gotowymi opcjami. Warto jednak monitorować rozmiar paczki i unikać ładowania nieużywanych skryptów.

Zaawansowane użycie: aktualizacja i reset

// Aktualizacja docelowej wartości
function updateCountUp(el, newValue) {
  if (!el._countup) return;
  el._countup.update(newValue);
}

// Inicjalizacja z referencją
function initCountUpWithRef(el) {
  const target = parseFloat(el.dataset.target);
  const cu = new window.CountUp.CountUp(el, target, { duration: 1.2 });
  if (!cu.error) {
    el._countup = cu;
    cu.start();
  }
}

SSR i frameworki

W środowiskach z renderowaniem po stronie serwera (SSR) lub w frameworkach SPA pamiętaj o warunku “tylko w przeglądarce” i o re-inicjalizacji po montażu komponentu. Zwróć uwagę na unikalne klucze i unikanie wielokrotnej inicjalizacji tego samego elementu (np. po nawigacji klienta w Next.js lub Nuxt).

Integracja w WordPress

Gutenberg: blok HTML lub własny blok

Najprościej: wstaw blok “Niestandardowy HTML” z markupem licznika, a JS dołącz globalnie w functions.php lub poprzez wtyczkę menedżera fragmentów. Jeśli tworzysz własny blok, przekaż opcje jako atrybuty i renderuj odpowiednie data-*, aby skrypt działał niezależnie od miejsca wstawienia.

Enqueue skryptów i lokalizacja

// functions.php
function theme_counters_assets() {
  wp_enqueue_script(
    'counters',
    get_template_directory_uri() . '/assets/js/counters.js',
    array(),
    '1.0.0',
    true
  );
}
add_action('wp_enqueue_scripts', 'theme_counters_assets');

Jeśli wartości liczb pochodzą z zaplecza, użyj wp_localize_script lub wp_add_inline_script, aby przekazać konfigurację do JS. Pamiętaj o wersjonowaniu plików i o tym, by nie dublować skryptu na każdej podstronie, jeśli nie jest potrzebny.

Shortcode do liczników

Krótkie kody pozwalają wstawiać licznik w dowolnym miejscu treści. Poniższy przykład generuje markup z atrybutami data-.

// functions.php
function sc_counter($atts) {
  $a = shortcode_atts(array(
    'target' => '1000',
    'duration' => '1200',
    'decimals' => '0',
    'prefix' => '',
    'suffix' => '',
    'label'  => 'Licznik'
  ), $atts);

  ob_start(); ?>
  <div class="stat">
    <div class="stat__value"
         data-target="<?php echo esc_attr($a['target']); ?>"
         data-duration="<?php echo esc_attr($a['duration']); ?>"
         data-decimals="<?php echo esc_attr($a['decimals']); ?>"
         data-prefix="<?php echo esc_attr($a['prefix']); ?>"
         data-suffix="<?php echo esc_attr($a['suffix']); ?>"
         aria-live="polite">0</div>
    <div class="stat__label"><?php echo esc_html($a['label']); ?></div>
  </div>
  <?php return ob_get_clean();
}
add_shortcode('counter', 'sc_counter');

Elementor i inne page buildery

W Elementorze możesz użyć widgetu “licznik” wbudowanego w Pro, lub dodać własny HTML/CSS/JS. Zaletą gotowych widgetów jest szybka konfiguracja, ale pamiętaj o kontroli nad wydajnośćią i konfliktem stylów. Testuj wyzwalanie animacji w widoku, bo niektóre buildery ładują zawartość sekcji dynamicznie (lazy load), co wymaga ponownego podczepienia obserwatora.

Optymalizacja, dostępność i testowanie

Dostępność: role, aria-live i alternatywa

Dodaj aria-live=”polite”, aby czytniki ekranu ogłosiły zmianę. Gdy licznik ma etykietę obok, użyj aria-label dla wartości, aby kontekst był jednoznaczny. Jeśli licznik jest kluczowy, zapewnij też wersję tekstową bez animacji w DOM (np. w elemencie ukrytym na ekranie, ale widocznym dla screen readera).

.sr-only {
  position: absolute;
  width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}

Uwaga: Nie polegaj na samym kolorze lub ruchu, by przekazać sens. Animacja ma być dodatkiem, a nie jedynym nośnikiem informacji.

Preferencje ruchu i redukcja animacji

Szanuj prefers-reduced-motion. W CSS możesz również uspokoić subtelne przejścia:

@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

Wydajność: obserwatory i pamięć

  • Twórz jeden obserwator na grupę liczników, nie jeden na element.
  • Używaj opcji once (manualnie: unobserve po odpaleniu), aby uniknąć kosztów dalszych obliczeń.
  • Limituj liczbę aktywnych animacji równocześnie (przy ogromnej siatce), jeśli to konieczne.
  • Odłącz obserwatory przy demontażu widoków w aplikacjach SPA, aby uniknąć wycieków pamięci.

Precyzja i zaokrąglanie

W animacjach do wartości zmiennoprzecinkowych unikaj drżenia i niejednoznaczności: licz ostatecznie do dokładnej wartości docelowej i wyświetlaj ją na końcu pętli. Dla walut stosuj z góry zdefiniowaną liczbę miejsc dziesiętnych.

SEO i degradacja funkcjonalna

Roboty wyszukiwarek nie zawsze uruchamiają skrypty. Dodaj statyczne wartości w HTML (np. w nocie <noscript> lub domyślnej treści elementu), tak aby strona nie traciła sensu bez JS. W krytycznych sekcjach możesz serwować docelowe wartości już w HTML i jedynie “przelatywać” animacją po załadowaniu.

Testy: przypadki brzegowe

  • Małe i duże wartości: 0, liczby ujemne, bardzo duże (miliony, miliardy).
  • Różne liczby miejsc dziesiętnych i różne ustawienia skracania (K, M, B).
  • Zachowanie po szybkim przewinięciu i podczas zmiany rozmiaru okna.
  • Współpraca ze skracaniem/formatami lokalnymi i różnymi fontami cyfr.
  • Tryb wysokiego kontrastu i preferencje ruchu.

Checklist wdrożeniowy

  • Elementy mają poprawny kontekst i etykiety (aria-label, aria-live).
  • Skrypt używa IntersectionObserver do wyzwalania i czyści obserwacje.
  • Animacja realizowana przez requestAnimationFrame, bez setInterval.
  • Dane formatowane zgodnie z lokalizacją; wprowadzone separatory i przecinki.
  • Uwzględniono prefers-reduced-motion oraz alternatywę bez animacji.
  • Skrypty łączone i minifikowane dla lepszej optymalizacjay.

Rozszerzenia i pomysły dodatkowe

  • Licznik “od końca” (countdown) – zmień kierunek animacji i dodaj wyzwalacz czasowy.
  • Łączenie z wykresami: po zakończeniu licznika odpal wykres słupkowy dla kontekstu.
  • Tryb “live”: okresowe aktualizacje z API z płynnym przejściem między wartościami.
  • Wielojęzyczność: wykorzystaj Intl.NumberFormat dla różnych rynków.

Przykład kompletnego pliku JS (bez bibliotek)

(function () {
  const easings = {
    linear: t => t,
    easeOut: t => 1 - Math.pow(1 - t, 3),
  };

  function formatNumber(value, decimals = 0, short = false) {
    const abs = Math.abs(value);
    let unit = '';
    let num = value;

    if (short) {
      if (abs >= 1e9) { num = value / 1e9; unit = 'B'; }
      else if (abs >= 1e6) { num = value / 1e6; unit = 'M'; }
      else if (abs >= 1e3) { num = value / 1e3; unit = 'K'; }
    }

    const fixed = num.toFixed(decimals);
    const parts = fixed.split('.');
    const grouped = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '\u00A0');
    return parts[1] ? `${grouped},${parts[1]}${unit}` : `${grouped}${unit}`;
  }

  function animateCounter(el) {
    const target = parseFloat(el.dataset.target);
    const duration = parseInt(el.dataset.duration || 1200, 10);
    const decimals = parseInt(el.dataset.decimals || 0, 10);
    const prefix = el.dataset.prefix || '';
    const suffix = el.dataset.suffix || '';
    const easingName = el.dataset.easing || 'linear';
    const short = el.dataset.short === 'true';
    const ease = easings[easingName] || easings.linear;
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

    if (reduced) {
      el.textContent = `${prefix}${formatNumber(target, decimals, short)}${suffix}`;
      return Promise.resolve();
    }

    let start = null;
    const from = parseFloat((el.textContent || '0').replace(/\s/g, '').replace(',', '.')) || 0;

    return new Promise(resolve => {
      function frame(ts) {
        if (!start) start = ts;
        const t = Math.min((ts - start) / duration, 1);
        const current = from + (target - from) * ease(t);
        el.textContent = `${prefix}${formatNumber(current, decimals, short)}${suffix}`;
        if (t < 1) requestAnimationFrame(frame);
        else {
          el.textContent = `${prefix}${formatNumber(target, decimals, short)}${suffix}`;
          resolve();
        }
      }
      requestAnimationFrame(frame);
    });
  }

  function initCountersOnce(root = document) {
    const nodes = Array.from(root.querySelectorAll('.stat__value'));
    if (!('IntersectionObserver' in window)) {
      nodes.forEach(animateCounter);
      return;
    }
    const io = new IntersectionObserver((entries, obs) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          animateCounter(entry.target).then(() => obs.unobserve(entry.target));
        }
      });
    }, { threshold: 0.35 });
    nodes.forEach(n => io.observe(n));
  }

  document.addEventListener('DOMContentLoaded', () => {
    initCountersOnce(document);
  });

  // API globalne (opcjonalnie)
  window.Counters = { animateCounter, initCountersOnce };
})();

Najczęstsze błędy i jak ich uniknąć

  • Podwójna inicjalizacja – sprawdzaj, czy element nie ma już uruchomionej animacji lub nie został zainicjalizowany wcześniej.
  • Błędy konwersji liczb – usuwaj spacje i zamieniaj przecinek na kropkę przy parseFloat.
  • Brak kontroli nad czasem – dla bardzo małych wartości skróć czas, by uniknąć “leniwej” animacji.
  • Wyświetlanie 0 po odświeżeniu – ustaw wartości początkowe w HTML, a skryptem tylko je “przejdź”.

Kiedy warto użyć biblioteki

Jeśli projekt wymaga bogatego zestawu opcji, pluginów i szybkiego wdrożenia w wielu miejscach, biblioteka będzie praktyczna. Jeśli jednak liczników jest kilka i chcesz pełnej kontroli, podejście “vanilla” da mniejszy narzut i łatwiejszy debugging. W obu przypadkach pamiętaj o SEO, o czytelności wartości oraz o tym, by animacja była środkiem, a nie celem.

< Powrót

Zapisz się do newslettera


Zadzwoń Napisz