StatsCounter Social Proof
Grote statistieken met count-up animatie (IntersectionObserver). Twee layouts: row (horizontaal) of grid. Dark variant.
src/components/sections/StatsCounter.astro
---
/**
* StatsCounter
* Grote statistieken met count-up animatie via IntersectionObserver.
* Twee layout opties: row (horizontaal) of grid.
* Dark variant optioneel via prop.
*/
interface Stat {
value: string;
suffix?: string;
label: string;
description?: string;
icon?: string;
}
interface Props {
preHeadline?: string;
headline?: string;
sub?: string;
stats: Stat[];
layout?: 'row' | 'grid';
dark?: boolean;
}
const {
preHeadline,
headline,
sub,
stats = [],
layout = 'row',
dark = false,
} = Astro.props;
---
<section
class:list={['sc', `sc--${layout}`, { 'sc--dark': dark }]}
data-component="stats-counter"
>
<div class="sc__inner">
{(preHeadline || headline || sub) && (
<div class="sc__header">
{preHeadline && <p class="sc__pre">{preHeadline}</p>}
{headline && <h2 class="sc__headline" set:html={headline} />}
{sub && <p class="sc__sub">{sub}</p>}
</div>
)}
<div class="sc__stats">
{stats.map((s, i) => (
<div class="sc__stat" style={`--delay:${i * 0.1}s`}>
{s.icon && (
<div class="sc__icon" set:html={s.icon} />
)}
<div class="sc__value-wrap">
<span
class="sc__value"
data-target={s.value.replace(/[^0-9.]/g, '')}
data-suffix={s.suffix ?? s.value.replace(/[0-9.]/g, '')}
>
{s.value}{s.suffix}
</span>
</div>
<p class="sc__label">{s.label}</p>
{s.description && <p class="sc__desc">{s.description}</p>}
</div>
))}
</div>
</div>
</section>
<script>
const sections = document.querySelectorAll('[data-component="stats-counter"]');
sections.forEach(section => {
const stats = section.querySelectorAll<HTMLElement>('.sc__value');
let animated = false;
function countUp(el: HTMLElement) {
const raw = el.dataset.target ?? '';
const suffix = el.dataset.suffix ?? '';
const isFloat = raw.includes('.');
const target = parseFloat(raw) || 0;
const duration = 1800;
const start = performance.now();
function update(now: number) {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const current = target * eased;
el.textContent = (isFloat ? current.toFixed(1) : Math.round(current).toString()) + suffix;
if (progress < 1) requestAnimationFrame(update);
}
requestAnimationFrame(update);
}
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && !animated) {
animated = true;
const delay = Number((entries[0].target as HTMLElement).style.getPropertyValue('--delay') ?? 0) * 1000;
stats.forEach((stat, i) => {
setTimeout(() => countUp(stat), i * 100);
});
}
},
{ threshold: 0.3 }
);
const wrapper = section.querySelector('.sc__stats');
if (wrapper) observer.observe(wrapper);
});
</script>
<style>
.sc {
padding: 5rem 1.5rem;
background: var(--color-bg, #fff);
}
.sc--dark {
background: var(--color-primary, #0a0a0a);
}
.sc__inner { max-width: 1200px; margin: 0 auto; }
/* Header */
.sc__header {
text-align: center;
max-width: 600px;
margin: 0 auto 4rem;
}
.sc__pre {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-accent, #6366f1);
margin-bottom: 0.875rem;
}
.sc__headline {
font-size: clamp(1.75rem, 3.5vw, 2.75rem);
font-weight: 800;
letter-spacing: -0.035em;
line-height: 1.1;
color: var(--color-primary, #0a0a0a);
margin-bottom: 0.875rem;
}
.sc--dark .sc__headline { color: #fff; }
.sc__headline :global(em) {
font-style: normal;
color: var(--color-accent, #6366f1);
}
.sc__sub {
font-size: 1rem;
line-height: 1.65;
color: var(--color-muted, #6b7280);
}
.sc--dark .sc__sub { color: rgba(255,255,255,0.45); }
/* Stats row */
.sc--row .sc__stats {
display: flex;
gap: 0;
justify-content: center;
flex-wrap: wrap;
}
.sc--row .sc__stat {
flex: 1;
min-width: 160px;
max-width: 280px;
text-align: center;
padding: 2rem 1.5rem;
border-right: 1px solid rgba(0,0,0,0.07);
}
.sc--dark.sc--row .sc__stat {
border-right-color: rgba(255,255,255,0.07);
}
.sc--row .sc__stat:last-child { border-right: none; }
/* Stats grid */
.sc--grid .sc__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
}
.sc--grid .sc__stat {
background: rgba(0,0,0,0.02);
border-radius: 1rem;
padding: 2rem;
border: 1px solid rgba(0,0,0,0.05);
}
.sc--dark.sc--grid .sc__stat {
background: rgba(255,255,255,0.04);
border-color: rgba(255,255,255,0.07);
}
/* Stat */
.sc__stat {
animation: sc-fade-up 0.6s var(--delay, 0s) cubic-bezier(0.22,1,0.36,1) both;
}
.sc__icon {
width: 40px;
height: 40px;
color: var(--color-accent, #6366f1);
margin: 0 auto 1rem;
}
.sc__value-wrap { overflow: hidden; }
.sc__value {
display: block;
font-size: clamp(2.5rem, 5vw, 4rem);
font-weight: 900;
letter-spacing: -0.04em;
line-height: 1;
color: var(--color-primary, #0a0a0a);
margin-bottom: 0.5rem;
font-feature-settings: "tnum";
}
.sc--dark .sc__value { color: #fff; }
/* Accent first digit styling option */
.sc__label {
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-primary, #0a0a0a);
margin-bottom: 0.375rem;
}
.sc--dark .sc__label { color: rgba(255,255,255,0.85); }
.sc__desc {
font-size: 0.8125rem;
line-height: 1.5;
color: var(--color-muted, #6b7280);
}
.sc--dark .sc__desc { color: rgba(255,255,255,0.35); }
@keyframes sc-fade-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.sc__stat { animation: none; }
}
@media (max-width: 600px) {
.sc--row .sc__stats { flex-direction: column; }
.sc--row .sc__stat { border-right: none; border-bottom: 1px solid rgba(0,0,0,0.07); max-width: none; }
.sc--row .sc__stat:last-child { border-bottom: none; }
.sc--dark.sc--row .sc__stat { border-bottom-color: rgba(255,255,255,0.07); }
}
</style>
Props
| Prop | Type | Default | Beschrijving |
|---|---|---|---|
stats * | Stat[] | — | Stats met value (getal), suffix, label en optioneel description |
layout | 'row' | 'grid' | 'row' | Horizontale rij of grid |
dark | boolean | false | Donkere achtergrond variant |
* = verplicht