Number Ticker — Free HTML CSS JS Animated Counter Snippet

Number Ticker · Animations · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Data-attribute targets
to, decimals, prefix, and suffix per stat.
Odometer easing
easeOutExpo spins down and settles.
Thousands separators
Formatted on the integer part each frame.
Decimal support
Fixed places preserved throughout.
Tabular figures
Equal-width digits prevent jitter.
Scroll-triggered
IntersectionObserver fires once in view.
Replayable
Re-run all tickers on demand.
Independent stats
Each animates on its own.

About this UI Snippet

Number Ticker — Stats That Count Up With an Odometer Ease

Screenshot of the Number Ticker snippet rendered live

The number ticker is the animated stat counter that rolls a value up from zero to its target with a satisfying spin-down — the figures you see in "trusted by" and metrics sections. This snippet builds it with plain HTML, CSS, and vanilla JavaScript, with proper formatting (thousands separators, decimals, prefixes and suffixes) and a scroll trigger.

Data-driven targets

Each number declares its target and formatting through data attributes: data-to for the value, optional data-decimals, data-prefix (like $), and data-suffix (like % or +). The script reads these, so the same animation handles money, percentages, and large counts without per-stat code. This keeps the markup declarative — you describe the number, the script animates it.

The ease-out spin-down

The animation runs on requestAnimationFrame with delta timing against a fixed duration. The progress is passed through an easeOutExpo curve (1 - 2^(-10p)), which starts fast and decelerates sharply as it approaches the target — exactly the feel of an odometer or counter spinning down and settling. A linear count-up looks mechanical; this easing is what gives the ticker its momentum and a clean stop. The displayed value each frame is target * eased, formatted and written to the element.

Correct number formatting

Raw counting produces ugly intermediate values, so a format() helper rounds (or fixes decimals) and inserts thousands separators into the integer part with a regex, leaving any decimal part alone. So 1240000 animates through 1,240,000 rather than a bare digit string, and 99.98 keeps its two decimals throughout. The numbers also use font-variant-numeric: tabular-nums so every digit has the same width — without it, the figure would jitter horizontally as digits change, which looks broken on a counting number.

Scroll-triggered

Counting before the stats are visible wastes the effect, so an IntersectionObserver starts all tickers when the section is 30% in view, then disconnects so it fires once. This is the standard, performant trigger for on-scroll animations — no scroll listener, no per-frame visibility checks. A replay button re-runs them for demos.

Why rAF over a CSS counter

CSS has no real way to animate a formatted number with separators and easing, so JavaScript drives it — but only with lightweight per-frame text updates, not layout changes. The gradient text fill and the tabular figures are CSS; the script only writes the formatted string each frame, which is cheap even for several stats at once.

Customizing it

Change the duration for a faster or slower roll, swap the easing (e.g. a gentler easeOutQuart), adjust formatting (currency symbols, locale separators via toLocaleString), or restyle the stat cards. Add as many stats as you like — each is independent. Pair it with a metric card grid or a logo marquee for a complete trust section.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSA row of stat cards renders with target values.
  2. 2
    Scroll them into viewEach number counts up with an ease-out spin-down.
  3. 3
    Note the formattingThousands separators, decimals, and $ / % / + show correctly.
  4. 4
    Click ReplayThe tickers re-run from zero.
  5. 5
    Add a statCopy a card and set its data-to and formatting.
  6. 6
    Tune the rollChange the duration and easing.

Real-world uses

Common Use Cases

Stat sections
Pair with a metric card grid.
Trust and traction
Follow a logo marquee of customers.
Dashboards
Animate KPIs on a status dashboard.
Landing metrics
Headline numbers above a testimonial wall.
Pricing pages
Show savings beside a pricing card.
Counter demos
A reference for eased, formatted count-ups.

Got questions?

Frequently Asked Questions

The progress is passed through an easeOutExpo curve, 1 minus 2 to the power of -10p, which starts fast and decelerates sharply as it nears the target — the feel of an odometer settling. The displayed value each frame is target times the eased progress. A linear count-up looks mechanical; this easing gives the momentum and clean stop.

A format helper rounds or fixes decimals, then inserts thousands separators into the integer part with a regex, leaving any decimal part alone. So a value animates through 1,240,000 rather than a bare digit string, and a percentage keeps its decimals. Prefixes and suffixes from data attributes are added around the formatted number.

The numbers use font-variant-numeric: tabular-nums, which gives every digit the same width. Without it, proportional digits change width as they count, so the figure would shift left and right each frame, which looks broken. Tabular figures keep the number visually stable while it rolls.

An IntersectionObserver starts all tickers when the section is 30% in view, then disconnects so they animate once. This avoids counting before the stats are visible, needs no scroll listener or per-frame visibility checks, and is the standard performant trigger for on-scroll animations. A replay button re-runs them.

Make a Ticker component that takes the target and formatting as props and runs the rAF loop in a mount effect (triggered by an IntersectionObserver), writing the formatted value to a ref'd element rather than state to avoid re-rendering every frame. Cancel the frame on unmount. The gradient and tabular CSS port directly; in Tailwind use tabular-nums and bg-clip-text.

Number Ticker — Export as HTML, React, Vue, Angular & Tailwind

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Number Ticker</title>
  <style>
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#08080f;color:#fff;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px}
    
    .nt-stats{display:flex;flex-wrap:wrap;gap:18px;align-items:flex-start;max-width:760px}
    .nt-stat{flex:1;min-width:170px;background:#12121f;border:1px solid #232338;border-radius:16px;padding:22px}
    .nt-num{font-size:clamp(30px,5vw,46px);font-weight:900;letter-spacing:-.02em;font-variant-numeric:tabular-nums;background:linear-gradient(120deg,#fff,#a5b4fc);-webkit-background-clip:text;background-clip:text;color:transparent;line-height:1.05}
    .nt-label{font-size:13px;color:#8b8ba3;margin-top:8px}
    
    .nt-replay{flex-basis:100%;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.14);color:#cfcfe0;font-family:inherit;font-size:13px;font-weight:600;padding:9px 16px;border-radius:999px;cursor:pointer;width:fit-content}
  </style>
</head>
<body>
  <section class="nt-stats" id="ntStats">
    <div class="nt-stat"><div class="nt-num" data-to="48295" data-prefix="$"></div><div class="nt-label">Revenue this month</div></div>
    <div class="nt-stat"><div class="nt-num" data-to="1240000" data-suffix="+"></div><div class="nt-label">API requests / day</div></div>
    <div class="nt-stat"><div class="nt-num" data-to="99.98" data-decimals="2" data-suffix="%"></div><div class="nt-label">Uptime</div></div>
    <button type="button" class="nt-replay" id="ntReplay">↻ Replay</button>
  </section>
  <script>
    var nums = Array.prototype.slice.call(document.querySelectorAll('.nt-num'));
    
    function format(v, decimals) {
      var s = decimals ? v.toFixed(decimals) : Math.round(v).toString();
      // Add thousands separators to the integer part only.
      var parts = s.split('.');
      parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
      return parts.join('.');
    }
    
    function animate(el) {
      var to = parseFloat(el.dataset.to);
      var decimals = parseInt(el.dataset.decimals || '0', 10);
      var prefix = el.dataset.prefix || '', suffix = el.dataset.suffix || '';
      var dur = 1900, start = null;
    
      function frame(t) {
        if (start === null) start = t;
        var p = Math.min((t - start) / dur, 1);
        // easeOutExpo: fast then settling, the satisfying "spin-down" of an odometer.
        var eased = p === 1 ? 1 : 1 - Math.pow(2, -10 * p);
        el.textContent = prefix + format(to * eased, decimals) + suffix;
        if (p < 1) requestAnimationFrame(frame);
      }
      requestAnimationFrame(frame);
    }
    
    function runAll() { nums.forEach(animate); }
    
    // Count up when the stats scroll into view (once), and on replay.
    var io = new IntersectionObserver(function (entries) {
      entries.forEach(function (e) { if (e.isIntersecting) { runAll(); io.disconnect(); } });
    }, { threshold: 0.3 });
    io.observe(document.getElementById('ntStats'));
    
    document.getElementById('ntReplay').addEventListener('click', runAll);
  </script>
</body>
</html>
Tailwind
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Number Ticker</title>
  <!-- Tailwind CSS v3+ — arbitrary value classes (e.g. bg-[#0f172a]) are valid -->
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    * {
      box-sizing:border-box;margin:0;padding:0
    }

    body {
      font-family:system-ui,-apple-system,sans-serif;background:#08080f;color:#fff;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px
    }
  </style>
</head>
<body>
  <section class="flex flex-wrap gap-[18px] items-start max-w-[760px]" id="ntStats">
    <div class="flex-1 min-w-[170px] bg-[#12121f] border border-[#232338] rounded-2xl p-[22px]"><div class="nt-num text-[clamp(30px,5vw,46px)] font-black tracking-[-.02em] [font-variant-numeric:tabular-nums] [background:linear-gradient(120deg,#fff,#a5b4fc)] [-webkit-background-clip:text] bg-clip-text text-transparent leading-[1.05]" data-to="48295" data-prefix="$"></div><div class="text-[13px] text-[#8b8ba3] mt-2">Revenue this month</div></div>
    <div class="flex-1 min-w-[170px] bg-[#12121f] border border-[#232338] rounded-2xl p-[22px]"><div class="nt-num text-[clamp(30px,5vw,46px)] font-black tracking-[-.02em] [font-variant-numeric:tabular-nums] [background:linear-gradient(120deg,#fff,#a5b4fc)] [-webkit-background-clip:text] bg-clip-text text-transparent leading-[1.05]" data-to="1240000" data-suffix="+"></div><div class="text-[13px] text-[#8b8ba3] mt-2">API requests / day</div></div>
    <div class="flex-1 min-w-[170px] bg-[#12121f] border border-[#232338] rounded-2xl p-[22px]"><div class="nt-num text-[clamp(30px,5vw,46px)] font-black tracking-[-.02em] [font-variant-numeric:tabular-nums] [background:linear-gradient(120deg,#fff,#a5b4fc)] [-webkit-background-clip:text] bg-clip-text text-transparent leading-[1.05]" data-to="99.98" data-decimals="2" data-suffix="%"></div><div class="text-[13px] text-[#8b8ba3] mt-2">Uptime</div></div>
    <button type="button" class="basis-full bg-[rgba(255,255,255,.06)] border border-[rgba(255,255,255,.14)] text-[#cfcfe0] font-[inherit] text-[13px] font-semibold py-[9px] px-4 rounded-[999px] cursor-pointer w-fit" id="ntReplay">↻ Replay</button>
  </section>
  <script>
    var nums = Array.prototype.slice.call(document.querySelectorAll('.nt-num'));
    
    function format(v, decimals) {
      var s = decimals ? v.toFixed(decimals) : Math.round(v).toString();
      // Add thousands separators to the integer part only.
      var parts = s.split('.');
      parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
      return parts.join('.');
    }
    
    function animate(el) {
      var to = parseFloat(el.dataset.to);
      var decimals = parseInt(el.dataset.decimals || '0', 10);
      var prefix = el.dataset.prefix || '', suffix = el.dataset.suffix || '';
      var dur = 1900, start = null;
    
      function frame(t) {
        if (start === null) start = t;
        var p = Math.min((t - start) / dur, 1);
        // easeOutExpo: fast then settling, the satisfying "spin-down" of an odometer.
        var eased = p === 1 ? 1 : 1 - Math.pow(2, -10 * p);
        el.textContent = prefix + format(to * eased, decimals) + suffix;
        if (p < 1) requestAnimationFrame(frame);
      }
      requestAnimationFrame(frame);
    }
    
    function runAll() { nums.forEach(animate); }
    
    // Count up when the stats scroll into view (once), and on replay.
    var io = new IntersectionObserver(function (entries) {
      entries.forEach(function (e) { if (e.isIntersecting) { runAll(); io.disconnect(); } });
    }, { threshold: 0.3 });
    io.observe(document.getElementById('ntStats'));
    
    document.getElementById('ntReplay').addEventListener('click', runAll);
  </script>
</body>
</html>
React
import React, { useEffect } from 'react';

// CSS — optionally move to NumberTicker.module.css
const css = `
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#08080f;color:#fff;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px}

.nt-stats{display:flex;flex-wrap:wrap;gap:18px;align-items:flex-start;max-width:760px}
.nt-stat{flex:1;min-width:170px;background:#12121f;border:1px solid #232338;border-radius:16px;padding:22px}
.nt-num{font-size:clamp(30px,5vw,46px);font-weight:900;letter-spacing:-.02em;font-variant-numeric:tabular-nums;background:linear-gradient(120deg,#fff,#a5b4fc);-webkit-background-clip:text;background-clip:text;color:transparent;line-height:1.05}
.nt-label{font-size:13px;color:#8b8ba3;margin-top:8px}

.nt-replay{flex-basis:100%;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.14);color:#cfcfe0;font-family:inherit;font-size:13px;font-weight:600;padding:9px 16px;border-radius:999px;cursor:pointer;width:fit-content}
`;

export default function NumberTicker() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var nums = Array.prototype.slice.call(document.querySelectorAll('.nt-num'));
    
    function format(v, decimals) {
      var s = decimals ? v.toFixed(decimals) : Math.round(v).toString();
      // Add thousands separators to the integer part only.
      var parts = s.split('.');
      parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
      return parts.join('.');
    }
    
    function animate(el) {
      var to = parseFloat(el.dataset.to);
      var decimals = parseInt(el.dataset.decimals || '0', 10);
      var prefix = el.dataset.prefix || '', suffix = el.dataset.suffix || '';
      var dur = 1900, start = null;
    
      function frame(t) {
        if (start === null) start = t;
        var p = Math.min((t - start) / dur, 1);
        // easeOutExpo: fast then settling, the satisfying "spin-down" of an odometer.
        var eased = p === 1 ? 1 : 1 - Math.pow(2, -10 * p);
        el.textContent = prefix + format(to * eased, decimals) + suffix;
        if (p < 1) requestAnimationFrame(frame);
      }
      requestAnimationFrame(frame);
    }
    
    function runAll() { nums.forEach(animate); }
    
    // Count up when the stats scroll into view (once), and on replay.
    var io = new IntersectionObserver(function (entries) {
      entries.forEach(function (e) { if (e.isIntersecting) { runAll(); io.disconnect(); } });
    }, { threshold: 0.3 });
    io.observe(document.getElementById('ntStats'));
    
    document.getElementById('ntReplay').addEventListener('click', runAll);
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{css}</style>
      <section className="nt-stats" id="ntStats">
        <div className="nt-stat"><div className="nt-num" data-to="48295" data-prefix="$"></div><div className="nt-label">Revenue this month</div></div>
        <div className="nt-stat"><div className="nt-num" data-to="1240000" data-suffix="+"></div><div className="nt-label">API requests / day</div></div>
        <div className="nt-stat"><div className="nt-num" data-to="99.98" data-decimals="2" data-suffix="%"></div><div className="nt-label">Uptime</div></div>
        <button type="button" className="nt-replay" id="ntReplay">↻ Replay</button>
      </section>
    </>
  );
}
React + Tailwind
import React, { useEffect } from 'react';
// Requires Tailwind CSS v3+ — https://tailwindcss.com/docs/installation
// Arbitrary value classes (e.g. bg-[#0f172a]) are valid Tailwind v3+

export default function NumberTicker() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var nums = Array.prototype.slice.call(document.querySelectorAll('.nt-num'));
    
    function format(v, decimals) {
      var s = decimals ? v.toFixed(decimals) : Math.round(v).toString();
      // Add thousands separators to the integer part only.
      var parts = s.split('.');
      parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
      return parts.join('.');
    }
    
    function animate(el) {
      var to = parseFloat(el.dataset.to);
      var decimals = parseInt(el.dataset.decimals || '0', 10);
      var prefix = el.dataset.prefix || '', suffix = el.dataset.suffix || '';
      var dur = 1900, start = null;
    
      function frame(t) {
        if (start === null) start = t;
        var p = Math.min((t - start) / dur, 1);
        // easeOutExpo: fast then settling, the satisfying "spin-down" of an odometer.
        var eased = p === 1 ? 1 : 1 - Math.pow(2, -10 * p);
        el.textContent = prefix + format(to * eased, decimals) + suffix;
        if (p < 1) requestAnimationFrame(frame);
      }
      requestAnimationFrame(frame);
    }
    
    function runAll() { nums.forEach(animate); }
    
    // Count up when the stats scroll into view (once), and on replay.
    var io = new IntersectionObserver(function (entries) {
      entries.forEach(function (e) { if (e.isIntersecting) { runAll(); io.disconnect(); } });
    }, { threshold: 0.3 });
    io.observe(document.getElementById('ntStats'));
    
    document.getElementById('ntReplay').addEventListener('click', runAll);
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{`
* {
  box-sizing:border-box;margin:0;padding:0
}

body {
  font-family:system-ui,-apple-system,sans-serif;background:#08080f;color:#fff;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px
}
      `}</style>
      <section className="flex flex-wrap gap-[18px] items-start max-w-[760px]" id="ntStats">
        <div className="flex-1 min-w-[170px] bg-[#12121f] border border-[#232338] rounded-2xl p-[22px]"><div className="nt-num text-[clamp(30px,5vw,46px)] font-black tracking-[-.02em] [font-variant-numeric:tabular-nums] [background:linear-gradient(120deg,#fff,#a5b4fc)] [-webkit-background-clip:text] bg-clip-text text-transparent leading-[1.05]" data-to="48295" data-prefix="$"></div><div className="text-[13px] text-[#8b8ba3] mt-2">Revenue this month</div></div>
        <div className="flex-1 min-w-[170px] bg-[#12121f] border border-[#232338] rounded-2xl p-[22px]"><div className="nt-num text-[clamp(30px,5vw,46px)] font-black tracking-[-.02em] [font-variant-numeric:tabular-nums] [background:linear-gradient(120deg,#fff,#a5b4fc)] [-webkit-background-clip:text] bg-clip-text text-transparent leading-[1.05]" data-to="1240000" data-suffix="+"></div><div className="text-[13px] text-[#8b8ba3] mt-2">API requests / day</div></div>
        <div className="flex-1 min-w-[170px] bg-[#12121f] border border-[#232338] rounded-2xl p-[22px]"><div className="nt-num text-[clamp(30px,5vw,46px)] font-black tracking-[-.02em] [font-variant-numeric:tabular-nums] [background:linear-gradient(120deg,#fff,#a5b4fc)] [-webkit-background-clip:text] bg-clip-text text-transparent leading-[1.05]" data-to="99.98" data-decimals="2" data-suffix="%"></div><div className="text-[13px] text-[#8b8ba3] mt-2">Uptime</div></div>
        <button type="button" className="basis-full bg-[rgba(255,255,255,.06)] border border-[rgba(255,255,255,.14)] text-[#cfcfe0] font-[inherit] text-[13px] font-semibold py-[9px] px-4 rounded-[999px] cursor-pointer w-fit" id="ntReplay">↻ Replay</button>
      </section>
    </>
  );
}
Vue
<template>
  <section class="nt-stats" id="ntStats">
    <div class="nt-stat"><div class="nt-num" data-to="48295" data-prefix="$"></div><div class="nt-label">Revenue this month</div></div>
    <div class="nt-stat"><div class="nt-num" data-to="1240000" data-suffix="+"></div><div class="nt-label">API requests / day</div></div>
    <div class="nt-stat"><div class="nt-num" data-to="99.98" data-decimals="2" data-suffix="%"></div><div class="nt-label">Uptime</div></div>
    <button type="button" class="nt-replay" id="ntReplay">↻ Replay</button>
  </section>
</template>

<script setup>
import { onMounted } from 'vue';

var nums = Array.prototype.slice.call(document.querySelectorAll('.nt-num'));
function format(v, decimals) {
  var s = decimals ? v.toFixed(decimals) : Math.round(v).toString();
  // Add thousands separators to the integer part only.
  var parts = s.split('.');
  parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  return parts.join('.');
}
function animate(el) {
  var to = parseFloat(el.dataset.to);
  var decimals = parseInt(el.dataset.decimals || '0', 10);
  var prefix = el.dataset.prefix || '', suffix = el.dataset.suffix || '';
  var dur = 1900, start = null;

  function frame(t) {
    if (start === null) start = t;
    var p = Math.min((t - start) / dur, 1);
    // easeOutExpo: fast then settling, the satisfying "spin-down" of an odometer.
    var eased = p === 1 ? 1 : 1 - Math.pow(2, -10 * p);
    el.textContent = prefix + format(to * eased, decimals) + suffix;
    if (p < 1) requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}
function runAll() { nums.forEach(animate); }
let io;

onMounted(() => {
  io = new IntersectionObserver(function (entries) {
    entries.forEach(function (e) { if (e.isIntersecting) { runAll(); io.disconnect(); } });
  }, { threshold: 0.3 });
  io.observe(document.getElementById('ntStats'));
  document.getElementById('ntReplay').addEventListener('click', runAll);
});
</script>

<style scoped>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#08080f;color:#fff;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px}

.nt-stats{display:flex;flex-wrap:wrap;gap:18px;align-items:flex-start;max-width:760px}
.nt-stat{flex:1;min-width:170px;background:#12121f;border:1px solid #232338;border-radius:16px;padding:22px}
.nt-num{font-size:clamp(30px,5vw,46px);font-weight:900;letter-spacing:-.02em;font-variant-numeric:tabular-nums;background:linear-gradient(120deg,#fff,#a5b4fc);-webkit-background-clip:text;background-clip:text;color:transparent;line-height:1.05}
.nt-label{font-size:13px;color:#8b8ba3;margin-top:8px}

.nt-replay{flex-basis:100%;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.14);color:#cfcfe0;font-family:inherit;font-size:13px;font-weight:600;padding:9px 16px;border-radius:999px;cursor:pointer;width:fit-content}
</style>
Angular
// @ts-nocheck
// Note: vanilla JS DOM manipulation is preserved as-is inside ngAfterViewInit().
// For idiomatic Angular, replace document.getElementById() with @ViewChild() refs
// and move state into component properties with two-way binding.
import { Component, AfterViewInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-number-ticker',
  standalone: true,
  imports: [CommonModule],
  encapsulation: ViewEncapsulation.None,
  template: `
    <section class="nt-stats" id="ntStats">
      <div class="nt-stat"><div class="nt-num" data-to="48295" data-prefix="$"></div><div class="nt-label">Revenue this month</div></div>
      <div class="nt-stat"><div class="nt-num" data-to="1240000" data-suffix="+"></div><div class="nt-label">API requests / day</div></div>
      <div class="nt-stat"><div class="nt-num" data-to="99.98" data-decimals="2" data-suffix="%"></div><div class="nt-label">Uptime</div></div>
      <button type="button" class="nt-replay" id="ntReplay">↻ Replay</button>
    </section>
  `,
  styles: [`
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#08080f;color:#fff;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:24px}
    
    .nt-stats{display:flex;flex-wrap:wrap;gap:18px;align-items:flex-start;max-width:760px}
    .nt-stat{flex:1;min-width:170px;background:#12121f;border:1px solid #232338;border-radius:16px;padding:22px}
    .nt-num{font-size:clamp(30px,5vw,46px);font-weight:900;letter-spacing:-.02em;font-variant-numeric:tabular-nums;background:linear-gradient(120deg,#fff,#a5b4fc);-webkit-background-clip:text;background-clip:text;color:transparent;line-height:1.05}
    .nt-label{font-size:13px;color:#8b8ba3;margin-top:8px}
    
    .nt-replay{flex-basis:100%;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.14);color:#cfcfe0;font-family:inherit;font-size:13px;font-weight:600;padding:9px 16px;border-radius:999px;cursor:pointer;width:fit-content}
  `]
})
export class NumberTickerComponent implements AfterViewInit {
  ngAfterViewInit(): void {
    var nums = Array.prototype.slice.call(document.querySelectorAll('.nt-num'));
    
    function format(v, decimals) {
      var s = decimals ? v.toFixed(decimals) : Math.round(v).toString();
      // Add thousands separators to the integer part only.
      var parts = s.split('.');
      parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
      return parts.join('.');
    }
    
    function animate(el) {
      var to = parseFloat(el.dataset.to);
      var decimals = parseInt(el.dataset.decimals || '0', 10);
      var prefix = el.dataset.prefix || '', suffix = el.dataset.suffix || '';
      var dur = 1900, start = null;
    
      function frame(t) {
        if (start === null) start = t;
        var p = Math.min((t - start) / dur, 1);
        // easeOutExpo: fast then settling, the satisfying "spin-down" of an odometer.
        var eased = p === 1 ? 1 : 1 - Math.pow(2, -10 * p);
        el.textContent = prefix + format(to * eased, decimals) + suffix;
        if (p < 1) requestAnimationFrame(frame);
      }
      requestAnimationFrame(frame);
    }
    
    function runAll() { nums.forEach(animate); }
    
    // Count up when the stats scroll into view (once), and on replay.
    var io = new IntersectionObserver(function (entries) {
      entries.forEach(function (e) { if (e.isIntersecting) { runAll(); io.disconnect(); } });
    }, { threshold: 0.3 });
    io.observe(document.getElementById('ntStats'));
    
    document.getElementById('ntReplay').addEventListener('click', runAll);

    // Bind inline-handler functions to the component so the template can call them
    Object.assign(this, { format, animate, runAll });
  }
}