Stats Counter Band
Headline numbers that count up on scroll, with real values in the HTML for no-JS and screen readers.
statscounternumbersanimated
Live Preview
Trusted at scale
- Developers using the kit
- 12,000+
- Components & sections
- 250+
- CDN uptime last 12 months
- 99.98%
- Median time to first byte
- <48ms
Usage
Copy and paste this code into your React component:
tsx
import { StatsCounterBand } from 'react-component-kit-ui/sections';
<StatsCounterBand stats={[
{ value: 12000, suffix: '+', label: 'Developers' },
{ value: 99.98, suffix: '%', label: 'Uptime', decimals: 2 },
]} variant="cards" />Manual Installation
If you are not using the npm package, create a new file and paste the following code: src/components/sections/StatsCounterBand.tsx + StatsCounterBand.css:
StatsCounterBand.tsx
'use client';
import { useEffect, useRef } from 'react';
import './StatsCounterBand.css';
/** class:list โ className */
function cx(...args: unknown[]): string {
const out: string[] = [];
const walk = (v: unknown) => {
if (!v) return;
if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
else if (Array.isArray(v)) v.forEach(walk);
else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
};
args.forEach(walk);
return out.join(' ');
}
interface Stat { value: number; prefix?: string; suffix?: string; label: string; decimals?: number }
/**
* StatsCounterBand โ Full-width band of headline numbers that count up when
* scrolled into view. Final values are in the HTML, so no-JS and screen
* readers always get the real number; motion respects reduced-motion.
*
* @param {string} title - Optional heading (visually hidden when `hideTitle`).
* @param {boolean} hideTitle - Visually hide the heading.
* @param {Array<{value: number, prefix?: string, suffix?: string, label: string, decimals?: number}>} stats - Numbers.
* @param {string} variant - Band style (divided|cards|plain).
* @param {string} size - Vertical rhythm (sm|md|lg).
* @param {string} className - Extra CSS class.
*/
export interface StatsCounterBandProps {
title?: string;
hideTitle?: boolean;
stats?: Stat[];
variant?: 'divided' | 'cards' | 'plain';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function StatsCounterBand({
title = 'Trusted at scale',
hideTitle = false,
stats = [
{ value: 12000, suffix: '+', label: 'Developers using the kit' },
{ value: 250, suffix: '+', label: 'Components & sections' },
{ value: 99.98, suffix: '%', label: 'CDN uptime last 12 months', decimals: 2 },
{ value: 48, prefix: '<', suffix: 'ms', label: 'Median time to first byte' },
],
variant = 'divided',
size = 'md',
className = '',
}: StatsCounterBandProps) {
const id = `scb-${Math.random().toString(36).slice(2, 8)}`;
const fmt = (s: Stat) => `${s.prefix ?? ''}${s.value.toLocaleString('en-US', { minimumFractionDigits: s.decimals ?? 0, maximumFractionDigits: s.decimals ?? 0 })}${s.suffix ?? ''}`;
// Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
const mounted = useRef(false);
useEffect(() => {
if (mounted.current) return;
mounted.current = true;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const els = document.querySelectorAll<HTMLElement>('.scb__value[data-count]');
if (!reduce && 'IntersectionObserver' in window) {
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const el = entry.target as HTMLElement;
io.unobserve(el);
const target = Number(el.dataset.count);
const dec = Number(el.dataset.decimals);
const pre = el.dataset.prefix || '';
const suf = el.dataset.suffix || '';
const final = el.textContent;
el.setAttribute('aria-label', final || '');
const start = performance.now();
const dur = 1400;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / dur);
const eased = 1 - Math.pow(1 - t, 3);
const v = target * eased;
el.textContent = `${pre}${v.toLocaleString('en-US', { minimumFractionDigits: dec, maximumFractionDigits: dec })}${suf}`;
if (t < 1) requestAnimationFrame(tick);
else el.textContent = final;
};
requestAnimationFrame(tick);
});
}, { threshold: 0.4 });
els.forEach((el) => io.observe(el));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className="cq-stats-counter-band-cq" data-rk-72cbca="">
<section className={`scb scb--${variant} scb--${size} ${className}`} aria-labelledby={id} data-rk-72cbca="">
<div className="scb__inner" data-rk-72cbca="">
<h2 id={id} className={cx(['scb__title', { 'scb__sr': hideTitle }])} data-rk-72cbca="">{title}</h2>
<dl className="scb__grid" data-rk-72cbca="">
{stats.map((s, __i: number) => (
<div key={__i} className="scb__item" data-rk-72cbca="">
<dt className="scb__label" data-rk-72cbca="">{s.label}</dt>
<dd className="scb__value" data-count={s.value} data-decimals={s.decimals ?? 0} data-prefix={s.prefix ?? ''} data-suffix={s.suffix ?? ''} data-rk-72cbca="">{fmt(s)}</dd>
</div>
))}
</dl>
</div>
</section>
</div>
</>
);
}
export default StatsCounterBand;StatsCounterBand.css
/* StatsCounterBand โ ported from astro-component-kit (sections/StatsCounterBand.astro) */
.cq-stats-counter-band-cq:where([data-rk-72cbca]) { container-type: inline-size; }
.scb:where([data-rk-72cbca]) {
--scb-py: 4.5rem;
padding: var(--scb-py) var(--sp-6, 1.5rem);
background: linear-gradient(180deg, transparent, rgb(var(--ink-rgb, 255 255 255) / 0.02) 50%, transparent), var(--c-bg, #050505);
}
.scb--sm:where([data-rk-72cbca]) { --scb-py: 3rem; }
.scb--lg:where([data-rk-72cbca]) { --scb-py: 6.5rem; }
.scb__inner:where([data-rk-72cbca]) { max-width: 1180px; margin: 0 auto; }
.scb__title:where([data-rk-72cbca]) { margin: 0 0 2.5rem; text-align: center; font-size: 0.875rem; font-weight: 650; letter-spacing: 0.1em; text-transform: uppercase; color: var(--c-text-2, #a3a3a3); }
.scb__sr:where([data-rk-72cbca]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; margin: 0; }
.scb__grid:where([data-rk-72cbca]) { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
.scb__item:where([data-rk-72cbca]) { display: flex; flex-direction: column-reverse; align-items: center; gap: 0.5rem; padding: 1.5rem 1rem; text-align: center; }
.scb--divided:where([data-rk-72cbca]) .scb__item:where([data-rk-72cbca]) + .scb__item:where([data-rk-72cbca]) { border-left: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.08); }
.scb--cards:where([data-rk-72cbca]) .scb__grid:where([data-rk-72cbca]) { gap: 1rem; }
.scb--cards:where([data-rk-72cbca]) .scb__item:where([data-rk-72cbca]) { border-radius: var(--r-lg, 20px); background: rgb(var(--ink-rgb, 255 255 255) / 0.03); border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.07)); }
.scb__value:where([data-rk-72cbca]) {
margin: 0;
font-size: clamp(2.25rem, 5vw, 3.25rem);
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1;
font-variant-numeric: tabular-nums;
background: linear-gradient(180deg, var(--c-text-1, #fff), rgb(var(--ink-rgb, 255 255 255) / 0.65));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.scb__label:where([data-rk-72cbca]) { font-size: 0.9375rem; color: var(--c-text-2, #a3a3a3); max-width: 220px; }
@container (max-width: 640px) {
.scb__grid:where([data-rk-72cbca]) { grid-template-columns: 1fr 1fr; }
.scb--divided:where([data-rk-72cbca]) .scb__item:where([data-rk-72cbca]) + .scb__item:where([data-rk-72cbca]) { border-left: 0; }
.scb--divided:where([data-rk-72cbca]) .scb__item:nth-child(even):where([data-rk-72cbca]) { border-left: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.08); }
}