325+ 325+
Projects completed
150+ 150+
Happy clients
98% 98%
Client retention
12,000+ 12,000+
Commits this year
@use 'sprucecss/scss/spruce' as *;
.number-card-list {
@include clear-list;
display: grid;
gap: spacer('l');
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
> li {
margin-block: 0;
}
}
.number-card {
@include layout-stack('xs');
border-block-end: config('border-width-base', $display) solid color('border');
margin-block: 0;
padding-block-end: spacer('l');
&__data {
color: color('primary');
font-family: config('font-family-heading', $typography);
font-size: responsive-font-size(4.5rem, 35);
font-variant-numeric: tabular-nums;
font-weight: 700;
line-height: 1;
}
&__caption {
color: color('muted');
font-weight: 600;
}
}
<ul class="number-card-list">
<li class="number-card">
<p class="number-card__data">
<span class="sr-only">325+</span>
<span aria-hidden="true"><span data-countup="325">325</span>+</span>
</p>
<p class="number-card__caption">Projects completed</p>
</li>
<li class="number-card">
<p class="number-card__data">
<span class="sr-only">150+</span>
<span aria-hidden="true"><span data-countup="150">150</span>+</span>
</p>
<p class="number-card__caption">Happy clients</p>
</li>
<li class="number-card">
<p class="number-card__data">
<span class="sr-only">98%</span>
<span aria-hidden="true"><span data-countup="98">98</span>%</span>
</p>
<p class="number-card__caption">Client retention</p>
</li>
<li class="number-card">
<p class="number-card__data">
<span class="sr-only">12,000+</span>
<span aria-hidden="true"><span data-countup="12000">12,000</span>+</span>
</p>
<p class="number-card__caption">Commits this year</p>
</li>
</ul>
(() => {
const counters = document.querySelectorAll('[data-countup]');
const duration = 2000;
const format = new Intl.NumberFormat(document.documentElement.lang || undefined);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const countUp = (element) => {
const target = Number(element.dataset.countup);
let start = null;
const step = (timestamp) => {
start ??= timestamp;
const progress = Math.min((timestamp - start) / duration, 1);
const eased = 1 - (1 - progress) ** 3;
element.textContent = format.format(Math.round(target * eased));
if (progress < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
countUp(entry.target);
observer.unobserve(entry.target);
}
});
}, { threshold: 0.5 });
counters.forEach((counter) => {
counter.textContent = format.format(0);
observer.observe(counter);
});
})();