x-data
Alpine
Alpine.js · Animation · requestAnimationFrame · IntersectionObserver
Animated Number Counter
Smooth ohne GSAP — reines Alpine.js und Browser-APIs

Animierte Zahlen-Counter sind auf Landing Pages, Statistik-Sektionen und Dashboards allgegenwärtig. Wer dafür GSAP oder CountUp.js einbindet, lädt unnötige Kilobytes. Mit Alpine.js, requestAnimationFrame und dem IntersectionObserver entsteht ein flüssiger, scroll-triggerter Counter – komplett ohne externe Libraries.

11 Min. Lesezeit requestAnimationFrame · Easing · IntersectionObserver · Intl.NumberFormat Alpine.js 3.x · Vanilla JS · Kein GSAP · Kein jQuery

1. Warum keinen GSAP oder CountUp.js verwenden?

GSAP ist eine exzellente Animation-Library – für komplexe, sequenzierte Animationen mit Timelines, SVG-Animationen und Plugin-Ökosystem ist sie kaum zu schlagen. CountUp.js ist eine spezialisierte Library für genau diesen Anwendungsfall. Aber beide haben einen Preis: GSAP (minified, ohne Plugins) ist ~70 KB, CountUp.js ~12 KB. Wenn das einzige Ziel ist, drei Zahlen auf einer Landing Page hochzuzählen, sind das unnötige Kilobytes – geladen, geparst und ausgeführt, ohne dass der Nutzer den Unterschied zur nativen Implementierung merkt.

Die Browser-API requestAnimationFrame wurde genau für Animationen entwickelt: synchronisiert mit dem Display-Refresh-Cycle (typischerweise 60 Hz), pausiert bei versteckten Tabs, nutzt die GPU-optimierten Rendering-Pfade des Browsers. Was GSAP unter der Haube macht, kann man direkt mit requestAnimationFrame implementieren – für einen Zahlen-Counter in etwa 30 Zeilen JavaScript. In Kombination mit Alpine.js wird diese Logik reaktiv und wiederverwendbar, ohne eine einzige externe Abhängigkeit. Das ist der Ansatz, den dieser Artikel umsetzt.

Ein weiterer Grund: Der Animations-Code bleibt direkt im Alpine.js-Komponenten-Scope. Das bedeutet: Die Animation-Logik, der angezeigte Wert und das Trigger-Event (Scroll, Click, Load) sind alle in einem x-data-Objekt zusammengefasst. Kein separater Initialisierungsaufruf, kein globaler Counter-Manager, keine Dependency auf window.CountUp. Das macht den Code selbst-dokumentierend und wartungsfreundlich.

2. Das Problem mit setInterval für Animationen

setInterval ist der intuitive erste Ansatz für Counting-Animationen: Alle N Millisekunden den Wert um einen Schritt erhöhen, bis der Zielwert erreicht ist. Das Problem: setInterval ist nicht mit dem Browser-Rendering-Zyklus synchronisiert. Bei einem 60-Hz-Display rendert der Browser alle 16,67 ms einen Frame. setInterval(fn, 16) klingt passend, aber JavaScript-Timers haben keine Frame-Synchronisation – der Callback kann mitten in einem Frame-Rendering feuern, was zu Rucklern, Tearing und doppelten Frames führt. Noch schlimmer: Bei einem überlasteten JavaScript-Thread verzögert sich setInterval ohne Anpassung der Animations-Logik, was die Animation entweder zu langsam oder abgehakt macht.

Außerdem läuft setInterval weiter, auch wenn der Tab im Hintergrund ist – unnötige CPU-Last und verbrauchte Akku-Kapazität auf mobilen Geräten. Wenn der Nutzer zum Tab zurückkehrt, kann die Animation bereits beendet sein oder in einem inkonsistenten Zustand hängen. requestAnimationFrame löst beide Probleme: Es feuert synchron mit dem nächsten Browser-Paint, pausiert automatisch bei Hintergrund-Tabs und liefert einen hochpräzisen Zeitstempel, über den die Animation exakt gesteuert werden kann – unabhängig von JavaScript-Event-Loop-Verzögerungen.

3. requestAnimationFrame: Animationen im Browser-Rhythmus

requestAnimationFrame(callback) registriert eine Funktion, die vor dem nächsten Browser-Paint aufgerufen wird. Der Callback bekommt einen DOMHighResTimeStamp – einen hochpräzisen Zeitstempel in Millisekunden seit dem Page-Load. Die Animation berechnet, wie weit sie basierend auf verstrichener Zeit fortgeschritten ist (Progress 0–1), nicht basierend auf einem Frame-Counter. Das macht die Animation framerate-unabhängig: Auf einem 30-Hz-Display läuft dieselbe Animation genauso lange wie auf einem 120-Hz-Display, nur mit unterschiedlich vielen Zwischenschritten.

Das Grundprinzip: Beim Start wird der Startzeitstempel gespeichert. Bei jedem Frame wird (currentTime - startTime) / duration berechnet – das ergibt den linearen Fortschritt von 0 bis 1. Dieser Progress wird durch eine Easing-Funktion transformiert (mehr dazu im nächsten Abschnitt) und dann auf den Werte-Bereich (Startwert bis Zielwert) gemappt. Solange Progress kleiner als 1, wird erneut requestAnimationFrame aufgerufen. Das ergibt eine selbst-regulierende, framerate-synchrone Animations-Schleife ohne externe 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-Funktionen: Warum linear langweilig ist

Eine lineare Animation – gleichmäßig von 0 auf 1000 in 2 Sekunden – fühlt sich mechanisch und unnatürlich an. Physikalische Bewegungen in der realen Welt sind nie linear: Ein Auto beschleunigt und bremst. Ein Ball trifft den Boden und prallt ab. Diese natürliche Charakteristik wird in Animationen durch Easing-Funktionen nachgebildet. Für einen Zahlen-Counter ist easeOutCubic die beste Wahl: schneller Start (die Zahl springt schnell auf die ersten Werte), sanftes Verlangsamen am Ende (die letzten Ziffern „schweben" in den Zielwert ein). Das erzeugt den Eindruck von Gewicht und Lebendigkeit.

Die mathematische Formel für easeOutCubic ist 1 - (1 - t)^3, wobei t der lineare Fortschritt von 0 bis 1 ist. Für noch dramatischere Verlangsamungen am Ende: easeOutQuint (1 - (1 - t)^5). Für Animationen, die langsam starten und sanft enden: easeInOutCubic (t < 0.5 ? 4*t³ : 1 - (-2*t+2)³/2). Alle diese Formeln sind reine JavaScript-Funktionen, die einen Wert zwischen 0 und 1 entgegennehmen und einen transformierten Wert zurückgeben – keine Library nötig, keine Abhängigkeit, nur Mathematik.

5. Die Alpine.js-Komponente: Grundstruktur

Die Komponente numberCounter nimmt Parameter über die Factory-Funktion entgegen: target (Zielwert), duration (Animationsdauer in ms), start (Startwert, Standard 0), decimals (Nachkommastellen), prefix (z.B. „€ "), suffix (z.B. „+"), und locale für die Zahlenformatierung. Das displayValue-Property enthält den aktuell angezeigten String und wird über x-text="displayValue" im Template ausgegeben. Die animate()-Methode startet die Animation, kann aber sicher mehrfach aufgerufen werden – ein Guard prüft, ob die Animation bereits läuft oder abgeschlossen ist.

Die destroy()-Methode cancelt einen laufenden requestAnimationFrame-Callback, falls die Komponente aus dem DOM entfernt wird, bevor die Animation abgeschlossen ist. Das verhindert Referenz-Fehler und unnötige CPU-Last. Alpine.js ruft destroy() automatisch auf, wenn ein Element mit x-data aus dem DOM entfernt wird – entweder durch x-if oder durch direkte DOM-Manipulation. Das macht die Komponente leak-frei, ohne dass der Entwickler manuell aufräumen muss.

6. IntersectionObserver: Animation bei Scroll-Sichtbarkeit

Ein Zahlen-Counter, der sofort beim Laden der Seite startet, ist verschwendet – die meisten Nutzer sehen ihn erst, wenn sie zu der entsprechenden Sektion gescrollt haben. Der IntersectionObserver ist die moderne, performante Lösung dafür: Er beobachtet ein Element und benachrichtigt, wenn es in den Viewport kommt, ohne dass im Scroll-Handler das Element-Offset berechnet werden muss. Das ist erheblich performanter als window.addEventListener('scroll', ...) mit getBoundingClientRect().

In der init()-Methode der Alpine.js-Komponente wird ein IntersectionObserver mit threshold: 0.3 erstellt – die Animation startet, wenn 30% des Elements sichtbar sind. this.$el ist das Element, auf dem x-data sitzt. Nach dem ersten Trigger wird der Observer mit observer.disconnect() deregistriert – die Animation soll nur einmal laufen, nicht bei jedem erneuten Einscrollen. Für eine destroy()-saubere Implementierung wird der Observer in einer Instanz-Variable gespeichert und beim Alpine.js-Destroy ebenfalls disconnected.


// 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. Zahlen-Formatierung: Intl.NumberFormat und Suffixe

Intl.NumberFormat ist die native Browser-API für lokalisierte Zahlenformatierung. new Intl.NumberFormat('de-DE').format(1234567) gibt 1.234.567 aus – mit deutschen Tausender-Punkten, ohne externe Formatierungs-Library. Für Währungen: new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(1234.5) gibt 1.234,50 € aus. Für Prozentzahlen: { style: 'percent', minimumFractionDigits: 1 }. Die API unterstützt alle gängigen Locales, Währungen und Formatierungsoptionen und ist in allen modernen Browsern verfügbar.

Für Counter mit Suffix wie „1.234 Kunden", „98,5 %", „€ 1,2 Mio." empfiehlt sich eine Hilfsfunktion, die während der Animation immer auf Ganzzahlen (oder gewünschte Dezimalstellen) rundet. Besonders beim Einsatz von Suffixen wie „Mio." oder „K" – wenn der Wert z.B. 1.200.000 ist, aber „1,2 Mio." angezeigt werden soll – ist eine Transformations-Funktion sinnvoll, die den Rohwert vor der Formatierung teilt. Diese Transformation lässt sich als optionaler Parameter in die Factory-Funktion einbauen: transform: (v) => v / 1000000.

8. Mehrere Counter gleichzeitig: Statistic-Grid-Komponente

Auf Landing Pages erscheinen Counter typischerweise als Gruppe – ein Raster aus drei bis vier Kennzahlen wie „2.500+ Kunden", „99,8% Uptime", „€ 4,5 Mio. Umsatz". Für dieses Pattern empfiehlt sich eine übergeordnete Container-Komponente, die den IntersectionObserver nur einmal für die gesamte Sektion registriert und dann alle Counter gleichzeitig startet. Das vermeidet N separate IntersectionObserver für N Counter und stellt sicher, dass alle Animationen synchron beginnen – was visuell viel befriedigender ist als N leicht versetzte Starts.

Das Muster: Der Container-div hat x-data="statSection()" mit einem eigenen IntersectionObserver. Jeder Counter-div darin hat x-data="numberCounter({...})" ohne eigenen IntersectionObserver. Wenn der Container sichtbar wird, dispatcht er ein Custom Event via this.$dispatch('start-counters'). Jede Counter-Komponente hört mit @start-counters.window="animate()" auf dieses Event. So bleibt jede Komponente eigenständig und wiederverwendbar, ohne eine direkte Abhängigkeit zwischen Container und Counter-Instanzen.

9. Accessibility: prefers-reduced-motion respektieren

Die CSS-Media-Query prefers-reduced-motion: reduce signalisiert, dass ein Nutzer in seinen System-Einstellungen animationsreduzierte Inhalte bevorzugt – typischerweise wegen vestibulärer Störungen, Epilepsie-Risikos oder Migräne. Das ist keine optionale Höflichkeit, sondern eine Accessibility-Anforderung (WCAG 2.1 AA). Für den Zahlen-Counter bedeutet das: Wenn window.matchMedia('(prefers-reduced-motion: reduce)').matches wahr ist, wird der Zielwert sofort ohne Animation gesetzt, statt die rAF-Schleife zu starten. Der Nutzer sieht den korrekten Wert, nur ohne die Counting-Animation.

Ebenso wichtig: aria-live="polite" auf dem Counter-Element stellt sicher, dass Screen Reader die Wertänderung ankündigen – aber erst, wenn die Animation abgeschlossen ist. Während der Animation wäre ein Screen-Reader-Announcement bei jedem Frame-Update brutal. Das Muster: aria-live="off" während der Animation, dann nach dem letzten Frame auf aria-live="polite" wechseln, damit der finale Wert announced wird. aria-label auf dem Element gibt einen beschreibenden Text für Screen Reader, der nicht von der animierten Zahl abhängt.


// 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: 'Zufriedene Kunden' })"
     :aria-live="ariaLive"
     :aria-label="label + ': ' + displayValue"
     x-text="displayValue">
</div>
*/

10. Zusammenfassung

Ein flüssiger, scroll-getriggerter Zahlen-Counter in Alpine.js braucht vier Zutaten: requestAnimationFrame für frame-synchrone, framerate-unabhängige Animationen, eine Easing-Funktion für natürliche Bewegung, den IntersectionObserver für den Scroll-Trigger und Intl.NumberFormat für lokalisierte Zahlenformatierung. Alles davon sind native Browser-APIs, die in jedem modernen Browser ohne Polyfill verfügbar sind. Der gesamte Animations-Code passt in ~50 Zeilen JavaScript, verursacht keinen Build-Step-Overhead und ist vollständig in einer Alpine.js-Komponente gekapselt.

Das Ergebnis ist performanter als die meisten GSAP-basierten Implementierungen, weil keine JavaScript-Library geparst und initialisiert werden muss – der Browser optimiert native requestAnimationFrame-Callbacks direkt. Für Accessibility wird prefers-reduced-motion respektiert und aria-live nach Animations-Ende aktiviert. Die Komponente ist parametrisierbar (Ziel, Dauer, Locale, Prefix, Suffix) und über benannte Alpine.js-Komponenten in einem ganzen Projekt wiederverwendbar, ohne eine Zeile Boilerplate-Code zu wiederholen.

Ansatz Bundle-Größe Frame-synchron Reduced-Motion
Alpine.js + rAF (dieser Artikel) 0 KB extra Ja Ja
CountUp.js ~12 KB gz Ja Manuell
GSAP gsap.to() ~70 KB gz Ja Plugin nötig
setInterval naiv 0 KB extra Nein Nein

Mironsoft

Alpine.js Animationen, Hyvä Themes und performante Frontend-Lösungen

Performante Animationen für euren Hyvä-Shop?

Wir implementieren flüssige, barrierefreie Alpine.js-Animationen für Magento 2 Hyvä-Themes – ohne externe Animation-Libraries, mit vollem Browser-API-Einsatz und maximaler Performance.

Counter & Stats

Scroll-getriggerte Statistik-Sektionen mit Alpine.js und nativer Browser-API

Animierte UIs

Transitions, Slide-Ins, Parallax-Effekte – alles ohne GSAP, mit x-transition und rAF

Performance-Audit

Bestehende Animationen auf Browser-APIs umstellen und Bundle-Größe reduzieren

Animated Number Counter — Das Wichtigste auf einen Blick

requestAnimationFrame

Frame-synchrone, framerate-unabhängige Animationen. Pausiert bei Hintergrund-Tabs. Zeitstempel-basierter Fortschritt statt Frame-Counter – kein rAF auf setInterval.

Easing-Funktion

easeOutCubic: 1 - (1-t)³. Schneller Start, sanftes Ende. Macht den Unterschied zwischen mechanisch-linearer und natürlich-angenehmer Animation.

IntersectionObserver

Scroll-Trigger ohne Scroll-Event-Handler. threshold: 0.3 startet bei 30% Sichtbarkeit. Observer nach erstem Trigger disconnecten – Animation nur einmal.

Accessibility

prefers-reduced-motion prüfen – Zielwert sofort setzen statt animieren. aria-live="polite" nur nach Animation aktivieren, nicht während der Counting-Schleife.

11. FAQ: Animated Number Counter mit Alpine.js

1Warum rAF statt setInterval?
requestAnimationFrame ist frame-synchron, pausiert bei Hintergrund-Tabs, liefert präzise Zeitstempel für zeitbasierte Animationen. setInterval ist nicht frame-synchron und ruckelt bei JS-Last.
2Framerate-unabhängige Animation?
Fortschritt als (verstrichene Zeit / Dauer) berechnen – 0 bis 1. Auf 30 Hz weniger Frames, gleiche Gesamtzeit. Immer gleich lang, unabhängig vom Display-Refresh-Rate.
3easeOutCubic vs. linear?
1-(1-t)³ – schneller Start, sanftes Ende. Imitiert natürliche Bewegung. Linear wirkt mechanisch. easeOutCubic lässt den Zielwert einrasten – visuell befriedigend.
4IntersectionObserver für Scroll-Trigger?
In init() registrieren, threshold: 0.3 (30% sichtbar). Bei Intersection animate() aufrufen, dann observer.disconnect(). Performanter als scroll-Event mit getBoundingClientRect.
5Lokale Zahlenformatierung?
Intl.NumberFormat('de-DE').format(n) – nativ, kein Extra-Bundle. Für Währungen style:'currency', für Prozent style:'percent'. Alle modernen Browser unterstützt.
6prefers-reduced-motion?
matchMedia('(prefers-reduced-motion: reduce)').matches prüfen. Wenn true: Zielwert sofort setzen. Accessibility-Pflicht für Nutzer mit Gleichgewichtsstörungen oder Epilepsie-Risiko.
7cancelAnimationFrame in destroy()?
Wenn das Element via x-if entfernt wird während Animation läuft, stoppt cancelAnimationFrame den Callback. Sonst greift er auf nicht-existentes Alpine-Scope zu – Fehler.
8Mehrere Counter synchron starten?
Container mit einem IntersectionObserver + $dispatch('start-counters'). Jeder Counter hört @start-counters.window='animate()'. Synchron, kein versetztes Timing.
9Dezimalzahlen animieren?
parseFloat((val).toFixed(decimals)) statt Math.round(). Intl.NumberFormat mit minimumFractionDigits/maximumFractionDigits für lokalisierte Dezimalstellen.
10Counter in Hyvä Phtml-Templates?
Alpine.data() in Inline-Script registrieren, $hyvaCsp->registerInlineScript() danach. x-data='scrollCounter({...})' und x-text='displayValue' im Phtml. Kein extra JS laden.