Skip to content
Velocity X Docs
← All components

Animated Stats Section

Free · MIT

Stat band whose numbers count up when the section scrolls into view, then settle. No runtime dependency — plain Astro plus a small inline observer. Honours prefers-reduced-motion by rendering final values immediately.

Category
Stats & Numbers
Framework
astro
Version
1.0.1
Peer deps
none

Install

bash
velocityx add animated-stats

Free and MIT licensed — no account needed. The file lands under src/components/velocity/animated-stats/ by default (override with --dest), and it's yours to edit from the moment it's written.

Demo

View the live demo on the showcase site ↗

Source

Pulled live from the registry — this is the exact file velocityx add animated-stats writes into your project right now.

components/velocity/animated-stats/AnimatedStats.astro

astro
---
//    VELOCITY COMPONENT · theming
// 
//    Colours here are Tailwind `primary-*` / `accent-*`, which resolve through
//    the Velocity preset. Out of the box they are a neutral indigo — NOT the
//    Aidxn brand — so this drops into your project without dragging our look in.
// 
//    ⚡ NOT USING VELOCITY? Run this once, in your project root:
// 
//          velocityx theme
// 
//    It writes velocity-preset.cjs + velocity-theme.css. Add the preset to your
//    tailwind.config, import the css, then set your whole brand in ONE place:
// 
//          :root {
//            --vx-brand: custom;
//            --vx-primary-600: 220 38 38;   <- your action colour
//            --vx-primary-700: 185 28 28;
//          }
// 
//    ⚠️  Raw RGB channels, space separated — NOT hex. `#DC2626` here silently
//        breaks every /opacity variant in the library (bg-primary-600/20 etc).
// 
//    Prefer not to install anything? Find-and-replace `primary-` with your own
//    scale name. Nothing else in this file depends on Velocity.
interface StatItem {
  value: number;
  suffix: string;
  label: string;
  description: string;
}

const stats: StatItem[] = [
  {
    // 15, not 10 — /about/ is the canonical E-E-A-T claim ("15 years across IT,
    // design, marketing and generative visuals", working "since 2011"), and the
    // hero and the home FAQ both say fifteen. This stat said 10+ and the H2 said
    // "Ten years", which contradicted the rest of the page.
    value: 15,
    suffix: '+',
    label: 'Years Experience',
    description: 'Professional web design and development across every major platform and framework.',
  },
  {
    value: 120,
    suffix: '+',
    label: 'Projects Delivered',
    description: 'Websites, apps, marketing campaigns, and brand identities shipped for clients across Australia.',
  },
  {
    value: 12,
    suffix: 'M+',
    label: 'Ad Spend Managed',
    description: 'Dollars of marketing spend managed across Google, Meta, and programmatic display networks.',
  },
  {
    value: 20,
    suffix: '+',
    label: 'Technologies',
    description: 'From React to Shopify, Python to After Effects -- we pick the right stack for every project.',
  },
];
---

<section class="v-section" data-parallax-speed="0.92" data-parallax-lag="1.0">
  <div class="w-full v-wide v-container-tight">
    <div class="grid lg:grid-cols-2 gap-6 sm:gap-8 lg:gap-12">

      <!-- Left column: text -->
      <div class="flex flex-col justify-center">
        <div class="flex flex-col gap-3 sm:gap-4">
          <p data-reveal="up" class="h-eyebrow text-primary-600 dark:text-primary-400">
            By The Numbers
          </p>
          <h2 data-reveal="up" style="--sr-delay: 90ms" class="h-section font-medium">
            Fifteen years, 120+ projects, $12M+ in managed ad spend.
          </h2>
          <p data-reveal="up" style="--sr-delay: 180ms" class="max-w-3xl text-primary-950/80 dark:text-primary-200/80 body-lg">
            Web design and marketing, all in one place. Honest and fair pricing structures.
            Manage your brand and marketing from a single point of contact, with a cohesive
            strategy between your website and your marketing, from the build through to ongoing
            <a
              href="/digital-marketing/google-search-management/"
              class="font-medium underline decoration-primary-400/50 underline-offset-2 hover:text-primary-600 dark:hover:text-primary-400"
            >Google Ads management</a>, Meta campaigns and
            <a
              href="/digital-marketing/seo/"
              class="font-medium underline decoration-primary-400/50 underline-offset-2 hover:text-primary-600 dark:hover:text-primary-400"
            >SEO on the Gold Coast</a>.
          </p>
          <div data-reveal="up" style="--sr-delay: 270ms" class="flex gap-3 mt-4">
            <a
              href="/services/"
              class="btn btn-primary"
            >
              All Services
            </a>
            <a
              href="/enquire/"
              class="btn btn-secondary"
            >
              Get a Quote
            </a>
          </div>
        </div>
      </div>

      <!-- Right column: animated stat cards -->
      <div data-reveal="up" style="--sr-delay: 180ms" class="w-full">
        <dl class="grid gap-3 sm:gap-5 grid-cols-2" id="stats-grid">
          {stats.map((stat, i) => (
            <div class="border border-primary-900/10 dark:border-primary-300/10 rounded-2xl px-4 py-5 sm:px-6 sm:py-7 lg:py-10 flex flex-col gap-2 hover:bg-primary-900/5 dark:hover:bg-primary-300/5 transition-colors duration-200">
              <dd class="flex items-baseline gap-0.5">
                <span
                  class="counter-value text-3xl xs:text-4xl lg:text-5xl text-primary-950 dark:text-primary-200 font-medium tracking-tight"
                  data-target={stat.value}
                >
                  0
                </span>
                <span class="text-2xl xs:text-3xl lg:text-4xl text-primary-600 dark:text-primary-400 font-medium tracking-tight">
                  {stat.suffix}
                </span>
              </dd>
              <dt class="text-primary-950 dark:text-primary-200 v-card-title font-medium">
                {stat.label}
              </dt>
              <p class="text-primary-950/60 dark:text-primary-200/60 body-sm mt-1">
                {stat.description}
              </p>
            </div>
          ))}
        </dl>
      </div>
    </div>
  </div>
</section>

<script is:inline>
  (function() {
    function mountCounters() {
      var grid = document.getElementById('stats-grid');
      if (!grid || grid.dataset.counterMounted === 'true') return;
      grid.dataset.counterMounted = 'true';
      var animated = false;

      function animateCounters() {
        if (animated) return;
        animated = true;

        grid.querySelectorAll('.counter-value').forEach(function(el) {
          var target = parseInt(el.getAttribute('data-target'), 10);
          var duration = 1600;
          var startTime = null;

          function step(timestamp) {
            if (!startTime) startTime = timestamp;
            var progress = Math.min((timestamp - startTime) / duration, 1);
            var eased = 1 - Math.pow(1 - progress, 3);
            el.textContent = Math.floor(eased * target);
            if (progress < 1) {
              requestAnimationFrame(step);
            } else {
              el.textContent = target;
            }
          }

          requestAnimationFrame(step);
        });
      }

      if (!('IntersectionObserver' in window)) {
        animateCounters();
        return;
      }
      var observer = new IntersectionObserver(function(entries) {
        entries.forEach(function(entry) {
          if (entry.isIntersecting) {
            animateCounters();
            observer.disconnect();
          }
        });
      }, { threshold: 0.3 });
      observer.observe(grid);
    }

    // Astro's client router replaces this section without re-running an inline
    // script. Mount on every page load so the counters also animate after a
    // mobile, client-side navigation.
    document.addEventListener('astro:page-load', mountCounters);
    if (document.readyState !== 'loading') mountCounters();
  })();
</script>

Theming

Uses the shared primary-* / accent-* scale — see Theming if colours look unstyled after install.