Animated Stats Section
Free · MITStat 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
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
---
// 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.