Stat Ticker
Free · MITCSS-only infinite marquee of stat tiles with a count-up animation triggered on scroll-into-view.
- Category
- Carousels & Tickers
- Framework
- astro
- Version
- 1.0.0
- Peer deps
- none
Install
velocityx add stat-ticker
Free and MIT licensed — no account needed. The file lands under src/components/velocity/stat-ticker/ 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 stat-ticker writes into your project right now.
components/velocity/stat-ticker/StatTicker.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.
/**
* StatTicker — horizontal scrolling strip of stat tiles (value + label).
* CSS-only infinite marquee like LogoTicker, but each tile is a
* value/label pair with a divider, suited to "by the numbers" strips.
* Count-up animation on scroll-into-view via a tiny inline script
* (vanilla, no deps), skipped entirely under prefers-reduced-motion.
*
* Usage:
* <StatTicker stats={[{ value: 2400, suffix: "+", label: "Sites shipped" }]} />
*/
export interface StatItem {
value: number;
prefix?: string;
suffix?: string;
label: string;
}
export interface Props {
stats: StatItem[];
speed?: number;
direction?: 'left' | 'right';
}
const { stats, speed = 32, direction = 'left' } = Astro.props;
const uid = `st-${Math.random().toString(36).slice(2, 9)}`;
---
<div class="relative w-full overflow-hidden [mask-image:linear-gradient(to_right,transparent,black_8%,black_92%,transparent)]" role="region" aria-label="Key stats">
<div
class={`${uid} flex w-max items-stretch motion-reduce:animate-none`}
style={`--st-duration:${speed}s; --st-direction:${direction === 'left' ? 'normal' : 'reverse'};`}
>
{[0, 1].map(() => (
<div class="flex items-stretch shrink-0">
{stats.map((s) => (
<div class="flex items-center gap-6 px-8 sm:px-12 py-6 border-r border-secondary-200 dark:border-secondary-800">
<p class="text-2xl sm:text-4xl font-bold text-secondary-900 dark:text-white whitespace-nowrap">
{s.prefix}<span class="js-stat-value" data-target={s.value}>0</span>{s.suffix}
</p>
<p class="text-xs sm:text-sm text-secondary-500 dark:text-secondary-400 whitespace-nowrap max-w-[10rem] leading-snug">{s.label}</p>
</div>
))}
</div>
))}
</div>
</div>
<style is:global>
@keyframes v-ticker-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
</style>
<style set:html={`
.${uid} {
animation: v-ticker-scroll var(--st-duration) linear infinite;
animation-direction: var(--st-direction);
}
.${uid}:hover, .${uid}:focus-within {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.${uid} {
animation: none !important;
flex-wrap: wrap;
width: 100%;
}
.${uid} > div:nth-child(2) {
display: none;
}
}
`}></style>
<script>
// Count-up on first intersection, one pass per element. Skips entirely
// under prefers-reduced-motion (jumps straight to the target value).
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function animateValue(el: HTMLElement) {
const target = Number(el.dataset.target || '0');
if (prefersReduced) {
el.textContent = target.toLocaleString();
return;
}
const duration = 1200;
const start = performance.now();
function tick(now: number) {
const progress = Math.min((now - start) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
el.textContent = Math.round(target * eased).toLocaleString();
if (progress < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
const seen = new WeakSet<Element>();
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !seen.has(entry.target)) {
seen.add(entry.target);
animateValue(entry.target as HTMLElement);
}
});
}, { threshold: 0.4 });
document.querySelectorAll('.js-stat-value').forEach((el) => io.observe(el));
</script>
Theming
Uses the shared primary-* / accent-* scale — see Theming if colours look unstyled after install.