Animated Number Counter: Smooth Without GSAP Using Alpine.js
AI generated
x-data
Alpine
Alpine.js · Animation · requestAnimationFrame · IntersectionObserver
Animated Number Counter
Smooth Without GSAP: Pure Alpine.js and Browser APIs

Animated number counters are everywhere on landing pages, statistics sections, and dashboards. Pulling in GSAP or CountUp.js for this loads unnecessary kilobytes. With Alpine.js, requestAnimationFrame, and the IntersectionObserver, you get a smooth, scroll-triggered counter, completely without external libraries.

11 min read requestAnimationFrame · Easing · IntersectionObserver · Intl.NumberFormat Alpine.js 3.x · Vanilla JS · No GSAP · No jQuery

1. Why Avoid GSAP or CountUp.js?

GSAP is an excellent animation library. For complex, sequenced animations with timelines, SVG animations, and a plugin ecosystem, it is hard to beat. CountUp.js is a library built specifically for this exact use case. But both come at a cost: GSAP (minified, without plugins) is about 70 KB, CountUp.js about 12 KB. If the only goal is counting up three numbers on a landing page, that is unnecessary weight: loaded, parsed, and executed without the user ever noticing a difference from a native implementation.

The browser API requestAnimationFrame was built exactly for animations: synchronized with the display refresh cycle (typically 60 Hz), paused on hidden tabs, and using the browser's GPU-optimized rendering paths. What GSAP does under the hood can be implemented directly with requestAnimationFrame, roughly 30 lines of JavaScript for a number counter. Combined with Alpine.js, this logic becomes reactive and reusable without a single external dependency. That is the approach this article takes.

Another reason: the animation code stays directly within the Alpine.js component scope. That means the animation logic, the displayed value, and the trigger event (scroll, click, load) all live together in one x-data object. No separate initialization call, no global counter manager, no dependency on window.CountUp. That makes the code self-documenting and easy to maintain.

2. The Problem with setInterval for Animations

setInterval is the intuitive first approach for counting animations: increase the value by one step every N milliseconds until the target is reached. The problem: setInterval is not synchronized with the browser's rendering cycle. On a 60 Hz display, the browser renders a frame every 16.67 ms. setInterval(fn, 16) sounds like a match, but JavaScript timers have no frame synchronization, so the callback can fire in the middle of a frame render, leading to stutter, tearing, and duplicate frames. Worse: under a busy JavaScript thread, setInterval falls behind without adjusting the animation logic, making the animation either too slow or choppy.

On top of that, setInterval keeps running even when the tab is in the background, wasting CPU and draining battery on mobile devices. When the user returns to the tab, the animation may already be finished or stuck in an inconsistent state. requestAnimationFrame solves both problems: it fires in sync with the next browser paint, automatically pauses on background tabs, and provides a high precision timestamp that lets the animation be controlled exactly, independent of JavaScript event loop delays.

3. requestAnimationFrame: Animating in the Browser's Rhythm

requestAnimationFrame(callback) registers a function that gets called before the next browser paint. The callback receives a DOMHighResTimeStamp, a high precision timestamp in milliseconds since the page loaded. The animation calculates how far it has progressed based on elapsed time (progress 0 to 1), not based on a frame counter. That makes the animation frame rate independent: on a 30 Hz display, the same animation runs for the same total duration as on a 120 Hz display, just with a different number of intermediate steps.

The basic principle: at the start, the start timestamp is stored. On every frame, (currentTime - startTime) / duration is calculated, giving the linear progress from 0 to 1. This progress is transformed by an easing function (more on that in the next section) and then mapped onto the value range (start value to target value). As long as progress is less than 1, requestAnimationFrame is called again. That produces a self-regulating, frame-rate-synchronized animation loop with no external dependencies.


// Alpine.js: Animated Number Counter with requestAnimationFrame
Alpine.data('numberCounter', ({
  target    = 1000,
  duration  = 2000,
  start     = 0,
  decimals  = 0,
  prefix    = '',
  suffix    = '',
  locale    = 'de-DE'
} = {}) => ({
  displayValue: prefix + start.toLocaleString(locale) + suffix,
  running: false,
  done: false,
  _rafId: null,

  // Easing: easeOutCubic, fast start, gentle finish
  _ease(t) { return 1 - Math.pow(1 - t, 3) },

  animate() {
    if (this.running || this.done) return
    // Respect prefers-reduced-motion
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      this.displayValue = this._format(target)
      this.done = true
      return
    }

    this.running = true
    const startTime = performance.now()
    const range = target - start

    const step = (currentTime) => {
      const elapsed  = currentTime - startTime
      const rawProg  = Math.min(elapsed / duration, 1)
      const progress = this._ease(rawProg)
      const current  = start + range * progress

      this.displayValue = this._format(current)

      if (rawProg < 1) {
        this._rafId = requestAnimationFrame(step)
      } else {
        this.running = false
        this.done = true
        this._rafId = null
      }
    }

    this._rafId = requestAnimationFrame(step)
  },

  _format(val) {
    const n = new Intl.NumberFormat(locale, {
      minimumFractionDigits: decimals,
      maximumFractionDigits: decimals
    }).format(val)
    return prefix + n + suffix
  },

  destroy() {
    if (this._rafId) cancelAnimationFrame(this._rafId)
  }
}))

4. Easing Functions: Why Linear Feels Boring

A linear animation, counting evenly from 0 to 1000 over 2 seconds, feels mechanical and unnatural. Physical motion in the real world is never linear: a car accelerates and brakes. A ball hits the ground and bounces. Easing functions recreate this natural quality in animations. For a number counter, easeOutCubic is the best choice: a fast start (the number jumps quickly through the first values), then a gentle slowdown at the end (the final digits ease into the target value). That creates a sense of weight and liveliness.

The mathematical formula for easeOutCubic is 1 - (1 - t)^3, where t is the linear progress from 0 to 1. For an even more dramatic slowdown at the end: easeOutQuint (1 - (1 - t)^5). For animations that start slowly and end gently: easeInOutCubic (t < 0.5 ? 4*t³ : 1 - (-2*t+2)³/2). All of these formulas are pure JavaScript functions that take a value between 0 and 1 and return a transformed value: no library needed, no dependency, just math.

5. The Alpine.js Component: Basic Structure

The numberCounter component accepts parameters through its factory function: target (the target value), duration (animation duration in ms), start (the starting value, default 0), decimals (number of decimal places), prefix (e.g. "$ "), suffix (e.g. "+"), and locale for number formatting. The displayValue property holds the currently shown string and is rendered in the template with x-text="displayValue". The animate() method starts the animation but can safely be called multiple times: a guard checks whether the animation is already running or has already finished.

The destroy() method cancels a running requestAnimationFrame callback if the component is removed from the DOM before the animation finishes. That prevents reference errors and unnecessary CPU load. Alpine.js calls destroy() automatically whenever an element with x-data is removed from the DOM, whether through x-if or direct DOM manipulation. That keeps the component leak free without requiring the developer to clean up manually.

6. IntersectionObserver: Animating on Scroll Visibility

A number counter that starts animating the moment the page loads is wasted: most users only see it once they have scrolled to that section. The IntersectionObserver is the modern, performant solution for this: it watches an element and notifies you when it enters the viewport, without having to calculate element offsets inside a scroll handler. That is considerably more performant than window.addEventListener('scroll', ...) combined with getBoundingClientRect().

In the Alpine.js component's init() method, an IntersectionObserver is created with threshold: 0.3, meaning the animation starts once 30% of the element is visible. this.$el is the element that x-data sits on. After the first trigger, the observer is deregistered with observer.disconnect(), since the animation should only run once, not every time the user scrolls past it again. For a clean destroy() implementation, the observer is stored in an instance variable and disconnected during Alpine.js's destroy as well.


// Alpine.js: Counter with IntersectionObserver scroll trigger
Alpine.data('scrollCounter', ({
  target = 500, duration = 1800, suffix = '', prefix = '', locale = 'de-DE'
} = {}) => ({
  displayValue: prefix + '0' + suffix,
  _observer: null,
  _rafId: null,

  ease(t) { return 1 - Math.pow(1 - t, 3) },

  init() {
    // Defer until visible in viewport
    this._observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        this._observer.disconnect()
        this._observer = null
        this.startAnimation()
      }
    }, { threshold: 0.3 })

    this._observer.observe(this.$el)
  },

  startAnimation() {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      this.displayValue = this.fmt(target); return
    }

    const startTime = performance.now()
    const step = (now) => {
      const t = Math.min((now - startTime) / duration, 1)
      const val = Math.round(target * this.ease(t))
      this.displayValue = prefix + val.toLocaleString(locale) + suffix
      if (t < 1) this._rafId = requestAnimationFrame(step)
      else this._rafId = null
    }
    this._rafId = requestAnimationFrame(step)
  },

  fmt(n) { return prefix + Math.round(n).toLocaleString(locale) + suffix },

  destroy() {
    this._observer?.disconnect()
    if (this._rafId) cancelAnimationFrame(this._rafId)
  }
}))

/* Usage in HTML:
<div class="text-5xl font-bold text-teal-700"
     x-data="scrollCounter({ target: 2500, suffix: '+', duration: 2000 })"
     x-text="displayValue">
</div>
*/

7. Number Formatting: Intl.NumberFormat and Suffixes

Intl.NumberFormat is the native browser API for locale-aware number formatting. new Intl.NumberFormat('en-US').format(1234567) outputs 1,234,567, with English thousands separators, no external formatting library required. For currencies: new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1234.5) outputs $1,234.50. For percentages: { style: 'percent', minimumFractionDigits: 1 }. The API supports every common locale, currency, and formatting option, and is available in all modern browsers.

For counters with suffixes like "1,234 customers", "98.5%", "$1.2M", a helper function that rounds to whole numbers (or the desired decimal places) throughout the animation is useful. This is especially true when using suffixes like "M" or "K": if the raw value is, say, 1,200,000 but you want to display "1.2M", a transform function that divides the raw value before formatting makes sense. This transformation can be added as an optional parameter to the factory function: transform: (v) => v / 1000000.

8. Multiple Counters at Once: a Statistic Grid Component

On landing pages, counters typically appear as a group: a grid of three or four metrics like "2,500+ customers", "99.8% uptime", "$4.5M revenue". For this pattern, a parent container component is a good idea: it registers the IntersectionObserver just once for the whole section and then starts all counters at the same time. That avoids N separate IntersectionObservers for N counters and ensures every animation begins in sync, which looks far more satisfying than N slightly staggered starts.

The pattern: the container div has x-data="statSection()" with its own IntersectionObserver. Each counter div inside it has x-data="numberCounter({...})" with no IntersectionObserver of its own. When the container becomes visible, it dispatches a custom event via this.$dispatch('start-counters'). Each counter component listens for this event with @start-counters.window="animate()". This keeps every component independent and reusable, with no direct dependency between the container and the counter instances.

9. Accessibility: Respecting prefers-reduced-motion

The CSS media query prefers-reduced-motion: reduce signals that a user has chosen reduced-motion content in their system settings, typically because of vestibular disorders, epilepsy risk, or migraines. This is not an optional courtesy, it is an accessibility requirement (WCAG 2.1 AA). For the number counter, that means: if window.matchMedia('(prefers-reduced-motion: reduce)').matches is true, the target value is set immediately without animation, instead of starting the rAF loop. The user sees the correct value, just without the counting animation.

Equally important: aria-live="polite" on the counter element ensures that screen readers announce the value change, but only once the animation is finished. During the animation, a screen reader announcement on every frame update would be brutal. The pattern: aria-live="off" while animating, then switch to aria-live="polite" after the final frame so the final value gets announced. aria-label on the element provides descriptive text for screen readers that does not depend on the animated number.


// Accessible Counter: prefers-reduced-motion + aria-live management
Alpine.data('accessibleCounter', ({
  target = 1000, duration = 2000, label = 'Anzahl', locale = 'de-DE', suffix = ''
} = {}) => ({
  displayValue: '0' + suffix,
  ariaLive: 'off',  // 'off' during animation, 'polite' at end
  _rafId: null,

  ease(t) { return 1 - Math.pow(1 - t, 3) },

  init() {
    const observer = new IntersectionObserver((entries) => {
      if (!entries[0].isIntersecting) return
      observer.disconnect()

      // Immediate display for reduced motion users
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        this.displayValue = target.toLocaleString(locale) + suffix
        this.ariaLive = 'polite'  // announce immediately
        return
      }

      const t0 = performance.now()
      const step = (now) => {
        const t   = Math.min((now - t0) / duration, 1)
        const val = Math.round(target * this.ease(t))
        this.displayValue = val.toLocaleString(locale) + suffix

        if (t < 1) {
          this._rafId = requestAnimationFrame(step)
        } else {
          this._rafId = null
          this.ariaLive = 'polite'  // announce only the final value
        }
      }
      this._rafId = requestAnimationFrame(step)

    }, { threshold: 0.4 })
    observer.observe(this.$el)
  },

  destroy() { if (this._rafId) cancelAnimationFrame(this._rafId) }
}))

/* Template:
<div x-data="accessibleCounter({ target: 2543, suffix: '+', label: 'Happy Customers' })"
     :aria-live="ariaLive"
     :aria-label="label + ': ' + displayValue"
     x-text="displayValue">
</div>
*/

10. Summary

A smooth, scroll-triggered number counter in Alpine.js needs four ingredients: requestAnimationFrame for frame-synchronized, frame-rate-independent animation, an easing function for natural motion, the IntersectionObserver for the scroll trigger, and Intl.NumberFormat for locale-aware number formatting. All of these are native browser APIs available in every modern browser with no polyfill required. The entire animation code fits into roughly 50 lines of JavaScript, adds no build step overhead, and is fully encapsulated inside a single Alpine.js component.

The result is more performant than most GSAP-based implementations, because no JavaScript library needs to be parsed and initialized: the browser optimizes native requestAnimationFrame callbacks directly. For accessibility, prefers-reduced-motion is respected and aria-live is activated after the animation ends. The component is fully parameterizable (target, duration, locale, prefix, suffix) and reusable across an entire project through named Alpine.js components, without repeating a single line of boilerplate.

Approach Bundle Size Frame Synchronized Reduced Motion
Alpine.js + rAF (this article) 0 KB extra Yes Yes
CountUp.js ~12 KB gz Yes Manual
GSAP gsap.to() ~70 KB gz Yes Plugin required
setInterval naive 0 KB extra No No

Mironsoft

Alpine.js animations, Hyvä themes, and performant frontend solutions

Need Performant Animations for Your Hyvä Shop?

We build smooth, accessible Alpine.js animations for Magento 2 Hyvä themes: no external animation libraries, full use of native browser APIs, and maximum performance.

Counter & Stats

Scroll-triggered statistics sections built with Alpine.js and native browser APIs

Animated UIs

Transitions, slide-ins, parallax effects, all without GSAP, using x-transition and rAF

Performance Audit

Migrate existing animations to native browser APIs and shrink your bundle size

Animated Number Counter: The Essentials at a Glance

requestAnimationFrame

Frame-synchronized, frame-rate-independent animation. Pauses on background tabs. Timestamp-based progress instead of a frame counter: no rAF running on top of setInterval.

Easing Function

easeOutCubic: 1 - (1-t)³. Fast start, gentle end. Makes the difference between a mechanical, linear animation and a natural, pleasant one.

IntersectionObserver

Scroll trigger without a scroll event handler. threshold: 0.3 starts at 30% visibility. Disconnect the observer after the first trigger: the animation should only run once.

Accessibility

Check prefers-reduced-motion: set the target value immediately instead of animating. Activate aria-live="polite" only after the animation, not during the counting loop.

11. FAQ: Animated Number Counter with Alpine.js

1Why rAF instead of setInterval?
requestAnimationFrame is frame-synchronized, pauses on background tabs, and provides precise timestamps for time-based animation. setInterval is not frame-synchronized and stutters under JS load.
2Frame-rate-independent animation?
Calculate progress as (elapsed time / duration): 0 to 1. Fewer frames at 30 Hz, same total duration. Always the same length regardless of the display refresh rate.
3easeOutCubic vs. linear?
1-(1-t)³: fast start, gentle end. Mimics natural motion. Linear feels mechanical. easeOutCubic makes the target value lock in, visually satisfying.
4IntersectionObserver for scroll trigger?
Register in init(), threshold: 0.3 (30% visible). Call animate() on intersection, then observer.disconnect(). More performant than a scroll event with getBoundingClientRect.
5Locale-aware number formatting?
Intl.NumberFormat('en-US').format(n): native, no extra bundle. Use style:'currency' for currencies, style:'percent' for percentages. Supported in all modern browsers.
6prefers-reduced-motion?
Check matchMedia('(prefers-reduced-motion: reduce)').matches. If true, set the target value immediately. An accessibility requirement for users with vestibular disorders or epilepsy risk.
7cancelAnimationFrame in destroy()?
If the element is removed via x-if while the animation is running, cancelAnimationFrame stops the callback. Otherwise it tries to access a non-existent Alpine scope, causing an error.
8Start multiple counters in sync?
Container with a single IntersectionObserver plus $dispatch('start-counters'). Each counter listens with @start-counters.window='animate()'. Synchronized, no staggered timing.
9Animate decimal numbers?
parseFloat((val).toFixed(decimals)) instead of Math.round(). Intl.NumberFormat with minimumFractionDigits/maximumFractionDigits for localized decimal places.
10Counter in Hyvä phtml templates?
Register Alpine.data() in an inline script, then $hyvaCsp->registerInlineScript(). Use x-data='scrollCounter({...})' and x-text='displayValue' in the phtml. No extra JS to load.