- Plan i wymagania funkcjonalne
- Co liczysz i po co?
- Jak ma działać animacja?
- Formaty i lokalizacja
- Dostępność i preferencje użytkownika
- Ograniczenia techniczne i wydajność
- Implementacja w czystym HTML, CSS i JS
- Struktura HTML z atrybutami danych
- Podstawowe style i przygotowanie do animacji
- Funkcja formatowania liczb
- Animacja z requestAnimationFrame
- Wyzwalanie animacji po wejściu w widok
- Obsługa wielu liczników i ponowne odtwarzanie
- Aktualizacja liczb z API
- Wariant z biblioteką CountUp.js
- Instalacja i podstawy
- Inicjalizacja z opcjami formatowania
- Zaawansowane użycie: aktualizacja i reset
- SSR i frameworki
- Integracja w WordPress
- Gutenberg: blok HTML lub własny blok
- Enqueue skryptów i lokalizacja
- Shortcode do liczników
- Elementor i inne page buildery
- Optymalizacja, dostępność i testowanie
- Dostępność: role, aria-live i alternatywa
- Preferencje ruchu i redukcja animacji
- Wydajność: obserwatory i pamięć
- Precyzja i zaokrąglanie
- SEO i degradacja funkcjonalna
- Testy: przypadki brzegowe
- Checklist wdrożeniowy
- Rozszerzenia i pomysły dodatkowe
- Przykład kompletnego pliku JS (bez bibliotek)
- Najczęstsze błędy i jak ich uniknąć
- Kiedy warto użyć biblioteki
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.