Number Counter Animation

Overview

A well-placed number counter animation makes stats feel alive: revenue ticking up, users joining, miles traveled. The motion draws the eye, says something’s happening here, and helps visitors grasp scale fast. In this guide, we’ll build an animated number counter with vanilla JS, compare counter libraries, and cover design, performance, and accessibility. If you want deeper setup for specific tools, see our focused guides on a clean CountUp.js setup and a rolling odometer effect.

There are two main styles:

  • Count‑up animation: a smooth increase from 0 (or any start) to a target. Clean, versatile, and easy to theme.
  • Odometer effect: digits roll like a speedometer. More theatrical, with mechanical charm.

Under the hood, these are just numbers updating over time—pure number animation with CSS/JS. The trick is to start when the element is on screen (an IntersectionObserver is perfect), format values properly, and respect reduced-motion settings. If you only need a quick embed or want to test options without touching code, we’ll show a no-code path too.

If your use case is a countdown rather than a count‑up, we have a concise walkthrough in Insert a Countdown Timer.

Add a number counter animation to your site instantly with MicroEdits

If you have an existing site and you just want working counters—now—MicroEdits is the shortest path. Describe the change in plain English—Add a count up animation to the stats in the hero and format totals as USD—and MicroEdits applies it on your live page. No coding, no dashboard spelunking.

  • Fast: Inject a tested counter snippet or a library-based embed into any page and preview changes safely.
  • Works everywhere: WordPress, Shopify, Webflow, Squarespace, custom stacks—if it’s on the web, MicroEdits can modify it.
  • Safe by design: Preview on the real page, share the preview link, then apply or roll back in one click.

enter any
website

You can ask for small touches—use compact notation (12k), only animate once per visit, start counting when the section is 30% in view—and it just happens. MicroEdits can also wire in tools like Calendly or Hotjar on the same pass so you’re not juggling plugins.

Vanilla JS approach

You don’t need a heavy counter library. A handful of lines—requestAnimationFrame plus IntersectionObserver—covers most cases cleanly.

Step 1. Markup
<!-- Use aria-live sparingly; see A11y notes below -->
<span class="counter" data-count-to="12345.67" data-duration="1200" data-decimals="2" data-prefix="$">0</span>

<!-- Optional: tabular numbers for steadier width -->
<style>
  .counter { font-variant-numeric: tabular-nums; }
</style>
Step 2. JavaScript (easing, decimals, IntersectionObserver)
// Smooth easing
const easeOutCubic = t => 1 - Math.pow(1 - t, 3);

function animateCounter(el) {
  const end = parseFloat(el.dataset.countTo || '0');
  const start = parseFloat(el.dataset.start || '0');
  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 locale = el.dataset.locale || undefined;

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

  // Locale-aware formatting
  const formatter = new Intl.NumberFormat(locale, {
    minimumFractionDigits: decimals,
    maximumFractionDigits: decimals
  });

  if (prefersReduced) {
    el.textContent = prefix + formatter.format(end) + suffix;
    return;
  }

  const startTime = performance.now();
  const delta = end - start;
  const factor = Math.pow(10, decimals);

  function frame(now) {
    const t = Math.min(1, (now - startTime) / duration);
    const eased = easeOutCubic(t);
    const current = start + delta * eased;

    // Avoid floating point artifacts
    const rounded = Math.round(current * factor) / factor;

    el.textContent = prefix + formatter.format(rounded) + suffix;

    if (t < 1) requestAnimationFrame(frame);
    else el.textContent = prefix + formatter.format(end) + suffix; // snap to final
  }

  requestAnimationFrame(frame);
}

// Start when counters appear
const io = new IntersectionObserver((entries, observer) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    const el = entry.target;
    if (el.dataset.animated) { observer.unobserve(el); continue; }
    animateCounter(el);
    el.dataset.animated = 'true';
    observer.unobserve(el);
  }
}, { threshold: 0.3 });

document.querySelectorAll('.counter').forEach(el => io.observe(el));

This covers:

  • requestAnimationFrame for smooth updates (see MDN).
  • IntersectionObserver to start when visible (no scroll handlers).
  • Decimals and locale-aware formatting with Intl.NumberFormat.

Tip: Keep the number’s container width stable. Tabular numerals and a fixed width container prevent layout shift.

Library options

When should you use a counter library? If you want a pre-styled odometer effect, increased API surface (pause/resume, scroll triggers baked-in), or you just prefer a battle-tested abstraction.

Quick picks

  • Odometer (rolling digits)

    • Pros: Themed odometer effect, playful motion, CSS-themable.
    • Cons: Heavier than a basic count-up, opinionated look.
    • See our guide: Odometer setup and theming.
  • CountUp.js (classic count-up animation)

    • Pros: Tiny, flexible, decimals/formatting, powerful API.
    • Cons: You still style it yourself (which is often a plus).
    • See: CountUp.js guide.
  • Counter‑Up (simple trigger-on-view)

    • Pros: One-liner feel; ideal for marketing pages.
    • Cons: Less control and extensibility than CountUp.js.
    • See: Counter‑Up usage notes.

If you adopt a counter library on production, consider lazy‑loading it—initialize the observer first, then load the library at the moment the section enters view to keep first‑load lean.

Design and formatting

Your counters should read cleanly at a glance. A few rules keep them honest and legible.

  • Use prefixes/suffixes: currency signs, units, and percentages.
  • Honor locale: separators change across regions (1,234.56 vs 1.234,56).
  • Keep decimals consistent: don’t jitter between 1.2 and 1.23 mid‑animation.
  • Compact large numbers: 12k, 3.4M.
Currency and locale-aware formatting
// Currency
const usd = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  maximumFractionDigits: 0
});
usd.format(12345); // "$12,345"

// Locale-aware decimal formatting
const de = new Intl.NumberFormat('de-DE', {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2
});
de.format(12345.67); // "12.345,67"

// Compact notation for big stats
const compact = new Intl.NumberFormat(undefined, {
  notation: 'compact',
  compactDisplay: 'short',
  maximumFractionDigits: 1
});
compact.format(12400); // "12.4K"
Prefix/suffix via data attributes
<span class="counter" data-count-to="98.5" data-suffix="%">0</span>
<span class="counter" data-count-to="2500" data-prefix="≈ " data-locale="en-GB">0</span>
Avoid layout shift
.counter {
  font-variant-numeric: tabular-nums; /* fixed-width digits */
}
.counter-wrap {
  min-width: 6ch; /* reserve space if the number grows */
}

Performance and accessibility

Smooth for everyone, respectful for those who prefer less motion.

  • Avoid layout thrash
    • Only update textContent. Don’t measure layout during the animation.
    • Reserve space so the number doesn’t push content around.
  • Reduced motion
    • If prefers-reduced-motion: reduce is set, snap to the final value instantly.
    • CSS fallback:
      @media (prefers-reduced-motion: reduce) {
        .counter { transition: none; }
      }
      
    • JS check shown in the vanilla snippet.
  • Pause offscreen
    • Don’t start until visible. If you loop or replay, stop updates once the element leaves the viewport using the same observer.
  • Mobile considerations
    • Shorten durations (700–1000ms). Ensure large hit areas and legible font sizes.
  • Screen readers
    • Counters can be noisy if they announce on every frame. Use aria-live="polite" judiciously, or announce only the final value:
      <span class="counter" aria-live="off" aria-label="Total signups">0</span>
      <!-- After the animation ends, set aria-live to 'polite' and update aria-label with the final value -->
      

For more on APIs mentioned above, see prefers-reduced-motion and ARIA live regions.

Common mistakes

  • Double-inits
    • Binding observers or library constructors twice leads to jumpy numbers. Mark elements with a data flag (e.g., data-animated="true").
  • Hydration fights
    • SSR frameworks may render a final number server‑side, then your client script rewrites it mid-hydration. Defer the counter until after hydration or dangerously delay initialization until requestIdleCallback.
  • Scroll-trigger collisions
    • Two observers (or a scroll library and your own) can both start the animation. Decide on a single trigger; unobserve after start.
  • Decimal flicker
    • Inconsistent rounding (e.g., 1.2 → 1.19 → 1.21) looks sloppy. Fix decimals with Intl.NumberFormat and round against a factor.
  • Layout shift
    • Not reserving width causes content jumps. Use tabular numerals and a min-width container.
  • Ignoring reduced motion
    • Always snap to the final value for users who opt out of animation.

FAQ

How long should a number counter animation last?

Aim for 0.8–1.6 seconds. Small stats can finish closer to 800ms; big numbers (hundreds of thousands) can stretch to ~2 seconds. Use easing—an ease‑out curve starts briskly and lands softly—so the animation feels responsive without lingering. If you detect mobile, trim ~15–20% off the duration to keep the rhythm snappy on smaller screens.

What easing works best for a count up animation?

An ease‑out curve is the crowd‑pleaser. easeOutCubic or easeOutQuad starts fast and gently coasts into the final value, which reads naturally for numbers. Linear easing can feel robotic, and strong elastic/bounce easings distract from the figure. Consistency matters more than the exact formula—pick one and use it everywhere.

Should I use IntersectionObserver or scroll events to trigger counters?

Use IntersectionObserver. It’s simpler, more efficient, and fires exactly when the element is in view—no math, no throttling. Scroll listeners are easy to get wrong and can cost you performance. The observer also makes it trivial to unobserve after the first run to prevent double animations.

Can I replay the counter when it re-enters the viewport?

Yes. Remove the data-animated guard and re‑observe elements, or provide a replay control that calls your animateCounter(el) function on demand. If you let it auto‑replay on scroll, cap the frequency so it doesn’t become visual noise—once per session per element is a sane default.

How do I format currencies, percentages, and compact values cleanly?

Use Intl.NumberFormat. It gives you locale‑aware separators and symbols without hand‑rolling logic. For currency: { style: 'currency', currency: 'USD' }. For percentages: multiply by 100 and add a suffix or use a formatter with fixed decimals. For large stats, { notation: 'compact' } turns 12400 into 12.4K.

What’s the difference between a count up and an odometer effect?

A classic animated number counter updates the whole number smoothly. An odometer effect rolls each digit independently, like a dashboard. The odometer style is more expressive and draws attention; a simple count‑up is cleaner and often better for dashboards where readability and restraint matter. See our Odometer guide for theming tips.

Is Lottie a good choice for a lottie counter or rolling digits?

Lottie shines for bespoke motion and illustrations. If your counter is part of a larger scene or brand animation, a Lottie asset can look great. For plain numbers, CSS/JS is leaner, accessible, and easier to localize and update. Consider Lottie when you need custom art direction; use JS for dynamic, data‑driven numbers.

I don’t want to touch code—how do I add counters today?

Use MicroEdits. Paste your URL, describe what you want—animate the hero stats to 12.4K with compact notation—and preview it live. You can keep iterating until it feels right, then apply or revert with one click. It works on WordPress, Shopify, and custom sites alike.