Open demo

CSS code

HTML
<div><span class="lbl">Centred row</span>
<section class="cu cu-row" aria-labelledby="cu-row-title">
  <h3 class="cu-sr" id="cu-row-title">Fieldnote in numbers</h3>
  <div class="cu-stat"><p class="cu-num"><span data-count>12,480</span></p><p class="cu-label">teams plan their week in Fieldnote</p></div>
  <div class="cu-stat"><p class="cu-num"><span data-count>98.6</span><span class="cu-unit">%</span></p><p class="cu-label">uptime over the last 12 months</p></div>
  <div class="cu-stat"><p class="cu-num"><span class="cu-unit">$</span><span data-count>4.2</span><span class="cu-unit">M</span></p><p class="cu-label">paid out to template creators</p></div>
  <div class="cu-stat"><p class="cu-num"><span data-count>320</span><span class="cu-unit">+</span></p><p class="cu-label">apps connect in two clicks</p></div>
</section></div>

<div><span class="lbl">Cards with accent numbers</span>
<div class="cu cu-cards">
  <article class="cu-card"><span class="cu-tag">September</span>
    <p class="cu-num"><span data-count>2.7</span><span class="cu-unit">M</span></p>
    <h3 class="cu-label">tasks ticked off</h3><p class="cu-note">Up from 2.1M in August, across 41 time zones.</p></article>
  <article class="cu-card"><span class="cu-tag">Per person</span>
    <p class="cu-num"><span data-count>41</span><span class="cu-unit"> min</span></p>
    <h3 class="cu-label">saved every week</h3><p class="cu-note">Median from time logs of 860 opted-in teams.</p></article>
  <article class="cu-card"><span class="cu-tag">Reviews</span>
    <p class="cu-num"><span data-count>4.9</span><span class="cu-unit">/5</span></p>
    <h3 class="cu-label">average rating</h3><p class="cu-note">From 3,100 verified reviews since 2023.</p></article>
</div></div>

<div><span class="lbl">Counters inside a headline</span>
<div class="cu cu-headline">
  <h3>Trusted by <span class="cu-hl"><span data-count>2,140</span></span> agencies in <span class="cu-hl"><span data-count>64</span></span> countries</h3>
  <p>From two-person studios in Porto to a 400-seat network in Singapore, teams run client work on Fieldnote.</p>
</div></div>
CSS
/* Count-up number counters. The HTML holds the final numbers; the script only runs the digits up when a
   counter scrolls into view. Units ($, %, M, +) sit outside [data-count] so they keep their own styling. */
.cu{--cu-ink:#0f172a;--cu-muted:#475569;--cu-accent:#4338ca;--cu-accent-soft:#eef2ff;--cu-line:#e2e8f0;--cu-card:#fff;
  color:var(--cu-ink)}
.cu-num{font-weight:800;letter-spacing:-.035em;line-height:1;font-variant-numeric:tabular-nums;white-space:nowrap}
.cu-unit{color:var(--cu-accent)}
.cu-sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}

/* 1. Centred row: hairlines between cells come from the 1px grid gap over a tinted background */
.cu-row{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;background:var(--cu-line);
  border:1px solid var(--cu-line);border-radius:20px;overflow:hidden;box-shadow:0 10px 30px -12px rgba(15,23,42,.12)}
.cu-row .cu-stat{background:var(--cu-card);padding:30px 18px 28px;text-align:center}
.cu-row .cu-num{font-size:clamp(2.1rem,4.4vw,3rem)}
.cu-row .cu-label{margin:10px auto 0;max-width:18ch;font-size:.92rem;color:var(--cu-muted)}

/* 2. Cards with an accent number */
.cu-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:18px}
.cu-card{position:relative;background:var(--cu-card);border:1px solid var(--cu-line);border-radius:18px;padding:24px 24px 22px;
  box-shadow:0 1px 2px rgba(15,23,42,.04),0 12px 28px -14px rgba(67,56,202,.25);overflow:hidden}
.cu-card::before{content:"";position:absolute;inset:0 0 auto;height:4px;background:linear-gradient(90deg,var(--cu-accent),#7c3aed)}
.cu-tag{display:inline-block;font-size:.72rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--cu-accent);
  background:var(--cu-accent-soft);border-radius:999px;padding:3px 10px}
.cu-card .cu-num{margin-top:16px;font-size:clamp(2.6rem,5vw,3.4rem);color:var(--cu-accent)}
.cu-card .cu-unit{font-size:.55em;letter-spacing:0;margin-left:2px}
.cu-card .cu-label{margin-top:8px;font-weight:600}
.cu-card .cu-note{margin-top:4px;font-size:.84rem;color:var(--cu-muted)}

/* 3. Counters inside a headline */
.cu-headline{text-align:center;padding:12px 0}
.cu-headline h3{font-size:clamp(1.6rem,4vw,2.6rem);font-weight:800;letter-spacing:-.03em;line-height:1.2;text-wrap:balance}
.cu-hl{color:var(--cu-accent);font-variant-numeric:tabular-nums;white-space:nowrap;
  background:linear-gradient(transparent 68%,var(--cu-accent-soft) 68%)}
.cu-headline p{margin:12px auto 0;max-width:52ch;color:var(--cu-muted)}

@media (max-width:760px){.cu-row{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (prefers-reduced-motion:reduce){.cu *{animation:none;transition:none}}
JavaScript
// Count-up on view. The text inside each [data-count] element is the real, final number, so it shows as-is
// without JavaScript and under prefers-reduced-motion (no count from 0). While the digits run, an invisible copy
// of the final number keeps the width steady (digits grow from the right, next to any unit) and a visually
// hidden copy is what screen readers get.
// Optional: data-from="…" (start value, default 0) and data-duration="…" (ms, default 1800).
(() => {
  const els = document.querySelectorAll('[data-count]');
  if (!els.length || !('IntersectionObserver' in window) || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
  const lang = document.documentElement.lang || undefined;
  const sep = type => new Intl.NumberFormat(lang).formatToParts(12345.6).find(p => p.type === type)?.value;
  const group = sep('group') || ',', point = sep('decimal') || '.';
  const esc = s => s.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
  const numRe = new RegExp(`^(\\D*?)(\\d[\\d${esc(group)}]*(?:${esc(point)}\\d+)?)(.*)$`, 's');
  const ease = t => 1 - (1 - t) ** 4;
  const span = (css, text) => { const s = document.createElement('span'); s.style.cssText = css; s.textContent = text; return s; };
  const jobs = new WeakMap();
  const run = el => {
    const j = jobs.get(el), t0 = performance.now();
    const tick = now => {
      const p = Math.min((now - t0) / j.dur, 1);
      if (p < 1) { j.live.textContent = j.pre + j.fmt.format(j.from + (j.to - j.from) * ease(p)) + j.post; requestAnimationFrame(tick); }
      else { el.textContent = j.final; el.style.display = ''; }
    };
    requestAnimationFrame(tick);
  };
  const io = new IntersectionObserver(list => list.forEach(e => {
    if (e.isIntersecting) { io.unobserve(e.target); run(e.target); }
  }), { threshold: 0.6 });
  els.forEach(el => {
    const final = el.textContent, m = final.trim().match(numRe);
    if (!m) return;
    const [, pre, num, post] = m, dec = num.includes(point) ? num.split(point)[1].length : 0;
    const to = parseFloat(num.split(group).join('').replace(point, '.')), from = parseFloat(el.dataset.from) || 0;
    const fmt = new Intl.NumberFormat(lang, { minimumFractionDigits: dec, maximumFractionDigits: dec, useGrouping: num.includes(group) });
    const live = span('grid-area:1/1;justify-self:end', pre + fmt.format(from) + post);
    live.setAttribute('aria-hidden', 'true');
    el.style.display = 'inline-grid';
    el.replaceChildren(span('grid-area:1/1;visibility:hidden', final.trim()), live,
      span('position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap', final.trim()));
    jobs.set(el, { final, pre, post, to, from, fmt, live, dur: +el.dataset.duration || 1800 });
    io.observe(el);
  });
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Centred row</span>
<section class="cu cu-row text-center" aria-labelledby="cu-row-title">
  <h3 class="visually-hidden" id="cu-row-title">Fieldnote in numbers</h3>
  <div><p class="cu-num"><span data-count>12,480</span></p><p class="small text-body-secondary mx-auto mt-2 mb-0">teams plan their week in Fieldnote</p></div>
  <div><p class="cu-num"><span data-count>98.6</span><span class="cu-unit">%</span></p><p class="small text-body-secondary mx-auto mt-2 mb-0">uptime over the last 12 months</p></div>
  <div><p class="cu-num"><span class="cu-unit">$</span><span data-count>4.2</span><span class="cu-unit">M</span></p><p class="small text-body-secondary mx-auto mt-2 mb-0">paid out to template creators</p></div>
  <div><p class="cu-num"><span data-count>320</span><span class="cu-unit">+</span></p><p class="small text-body-secondary mx-auto mt-2 mb-0">apps connect in two clicks</p></div>
</section></div>

<div><span class="lbl">Cards with accent numbers</span>
<div class="cu row row-cols-1 row-cols-md-3 g-3">
  <div class="col"><article class="card h-100"><div class="card-body"><span class="badge rounded-pill">September</span>
    <p class="cu-num mt-3"><span data-count>2.7</span><span class="cu-unit">M</span></p>
    <h3 class="h6 fw-semibold mt-2 mb-1">tasks ticked off</h3><p class="small text-body-secondary mb-0">Up from 2.1M in August, across 41 time zones.</p></div></article></div>
  <div class="col"><article class="card h-100"><div class="card-body"><span class="badge rounded-pill">Per person</span>
    <p class="cu-num mt-3"><span data-count>41</span><span class="cu-unit"> min</span></p>
    <h3 class="h6 fw-semibold mt-2 mb-1">saved every week</h3><p class="small text-body-secondary mb-0">Median from time logs of 860 opted-in teams.</p></div></article></div>
  <div class="col"><article class="card h-100"><div class="card-body"><span class="badge rounded-pill">Reviews</span>
    <p class="cu-num mt-3"><span data-count>4.9</span><span class="cu-unit">/5</span></p>
    <h3 class="h6 fw-semibold mt-2 mb-1">average rating</h3><p class="small text-body-secondary mb-0">From 3,100 verified reviews since 2023.</p></div></article></div>
</div></div>

<div><span class="lbl">Counters inside a headline</span>
<div class="cu text-center py-2">
  <h3 class="cu-title mb-0">Trusted by <span class="cu-hl"><span data-count>2,140</span></span> agencies in <span class="cu-hl"><span data-count>64</span></span> countries</h3>
  <p class="text-body-secondary mx-auto mt-3 mb-0" style="max-width:52ch">From two-person studios in Porto to a 400-seat network in Singapore, teams run client work on Fieldnote.</p>
</div></div>
CSS
/* Count-up counters on Bootstrap: .card, .badge and the grid utilities, with the accent set once in --cu-accent. */
.cu{--cu-accent:#4338ca;--cu-accent-soft:#eef2ff;--cu-line:#e2e8f0;--bs-body-color:#0f172a;--bs-secondary-color:#475569;color:#0f172a}
.cu-num{font-weight:800;letter-spacing:-.035em;line-height:1;font-variant-numeric:tabular-nums;white-space:nowrap;margin:0}
.cu-unit{color:var(--cu-accent)}
.cu-row{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;background:var(--cu-line);border:1px solid var(--cu-line);
  border-radius:20px;overflow:hidden;box-shadow:0 10px 30px -12px rgba(15,23,42,.12)}
.cu-row > div{background:#fff;padding:30px 18px 28px}
.cu-row .cu-num{font-size:clamp(2.1rem,4.4vw,3rem)}
.cu-row p.small{max-width:18ch;font-size:.92rem}
.cu .card{--bs-card-border-color:var(--cu-line);--bs-card-border-radius:18px;--bs-card-spacer-x:24px;--bs-card-spacer-y:24px;
  box-shadow:0 1px 2px rgba(15,23,42,.04),0 12px 28px -14px rgba(67,56,202,.25);overflow:hidden}
.cu .card::before{content:"";position:absolute;inset:0 0 auto;height:4px;background:linear-gradient(90deg,var(--cu-accent),#7c3aed)}
.cu .badge{--bs-badge-font-size:.72rem;--bs-badge-padding-x:10px;--bs-badge-padding-y:4px;letter-spacing:.06em;text-transform:uppercase;
  color:var(--cu-accent);background:var(--cu-accent-soft)}
.cu .card .cu-num{font-size:clamp(2.6rem,5vw,3.4rem);color:var(--cu-accent)}
.cu .card .cu-unit{font-size:.55em;letter-spacing:0;margin-left:2px}
.cu-hl{color:var(--cu-accent);font-variant-numeric:tabular-nums;white-space:nowrap;background:linear-gradient(transparent 68%,var(--cu-accent-soft) 68%)}
.cu h3.cu-title{font-size:clamp(1.6rem,4vw,2.6rem);font-weight:800;letter-spacing:-.03em;line-height:1.2;text-wrap:balance}
@media (max-width:760px){.cu-row{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (prefers-reduced-motion:reduce){.cu *{animation:none;transition:none}}
JavaScript
// Count-up on view. The text inside each [data-count] element is the real, final number, so it shows as-is
// without JavaScript and under prefers-reduced-motion (no count from 0). While the digits run, an invisible copy
// of the final number keeps the width steady (digits grow from the right, next to any unit) and a visually
// hidden copy is what screen readers get.
// Optional: data-from="…" (start value, default 0) and data-duration="…" (ms, default 1800).
(() => {
  const els = document.querySelectorAll('[data-count]');
  if (!els.length || !('IntersectionObserver' in window) || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
  const lang = document.documentElement.lang || undefined;
  const sep = type => new Intl.NumberFormat(lang).formatToParts(12345.6).find(p => p.type === type)?.value;
  const group = sep('group') || ',', point = sep('decimal') || '.';
  const esc = s => s.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
  const numRe = new RegExp(`^(\\D*?)(\\d[\\d${esc(group)}]*(?:${esc(point)}\\d+)?)(.*)$`, 's');
  const ease = t => 1 - (1 - t) ** 4;
  const span = (css, text) => { const s = document.createElement('span'); s.style.cssText = css; s.textContent = text; return s; };
  const jobs = new WeakMap();
  const run = el => {
    const j = jobs.get(el), t0 = performance.now();
    const tick = now => {
      const p = Math.min((now - t0) / j.dur, 1);
      if (p < 1) { j.live.textContent = j.pre + j.fmt.format(j.from + (j.to - j.from) * ease(p)) + j.post; requestAnimationFrame(tick); }
      else { el.textContent = j.final; el.style.display = ''; }
    };
    requestAnimationFrame(tick);
  };
  const io = new IntersectionObserver(list => list.forEach(e => {
    if (e.isIntersecting) { io.unobserve(e.target); run(e.target); }
  }), { threshold: 0.6 });
  els.forEach(el => {
    const final = el.textContent, m = final.trim().match(numRe);
    if (!m) return;
    const [, pre, num, post] = m, dec = num.includes(point) ? num.split(point)[1].length : 0;
    const to = parseFloat(num.split(group).join('').replace(point, '.')), from = parseFloat(el.dataset.from) || 0;
    const fmt = new Intl.NumberFormat(lang, { minimumFractionDigits: dec, maximumFractionDigits: dec, useGrouping: num.includes(group) });
    const live = span('grid-area:1/1;justify-self:end', pre + fmt.format(from) + post);
    live.setAttribute('aria-hidden', 'true');
    el.style.display = 'inline-grid';
    el.replaceChildren(span('grid-area:1/1;visibility:hidden', final.trim()), live,
      span('position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap', final.trim()));
    jobs.set(el, { final, pre, post, to, from, fmt, live, dur: +el.dataset.duration || 1800 });
    io.observe(el);
  });
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#526075] uppercase">Centred row</span>
<section class="grid grid-cols-2 gap-px overflow-hidden rounded-[20px] border border-cu-line bg-cu-line text-center shadow-[0_10px_30px_-12px_rgba(15,23,42,.12)] min-[761px]:grid-cols-4" aria-labelledby="cu-row-title">
  <h3 class="sr-only" id="cu-row-title">Fieldnote in numbers</h3>
  <div class="bg-white px-[18px] pt-[30px] pb-7"><p class="text-[clamp(2.1rem,4.4vw,3rem)] leading-none font-extrabold tracking-[-.035em] whitespace-nowrap tabular-nums"><span data-count>12,480</span></p><p class="mx-auto mt-2.5 max-w-[18ch] text-[.92rem] text-[#475569]">teams plan their week in Fieldnote</p></div>
  <div class="bg-white px-[18px] pt-[30px] pb-7"><p class="text-[clamp(2.1rem,4.4vw,3rem)] leading-none font-extrabold tracking-[-.035em] whitespace-nowrap tabular-nums"><span data-count>98.6</span><span class="text-cu-accent">%</span></p><p class="mx-auto mt-2.5 max-w-[18ch] text-[.92rem] text-[#475569]">uptime over the last 12 months</p></div>
  <div class="bg-white px-[18px] pt-[30px] pb-7"><p class="text-[clamp(2.1rem,4.4vw,3rem)] leading-none font-extrabold tracking-[-.035em] whitespace-nowrap tabular-nums"><span class="text-cu-accent">$</span><span data-count>4.2</span><span class="text-cu-accent">M</span></p><p class="mx-auto mt-2.5 max-w-[18ch] text-[.92rem] text-[#475569]">paid out to template creators</p></div>
  <div class="bg-white px-[18px] pt-[30px] pb-7"><p class="text-[clamp(2.1rem,4.4vw,3rem)] leading-none font-extrabold tracking-[-.035em] whitespace-nowrap tabular-nums"><span data-count>320</span><span class="text-cu-accent">+</span></p><p class="mx-auto mt-2.5 max-w-[18ch] text-[.92rem] text-[#475569]">apps connect in two clicks</p></div>
</section></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#526075] uppercase">Cards with accent numbers</span>
<div class="grid grid-cols-[repeat(auto-fit,minmax(min(100%,240px),1fr))] gap-[18px]">
  <article class="relative overflow-hidden rounded-[18px] border border-cu-line bg-white px-6 pt-6 pb-[22px] shadow-[0_1px_2px_rgba(15,23,42,.04),0_12px_28px_-14px_rgba(67,56,202,.25)] before:absolute before:inset-x-0 before:top-0 before:h-1 before:bg-linear-to-r before:from-cu-accent before:to-[#7c3aed]"><span class="inline-block rounded-full bg-cu-soft px-2.5 py-[3px] text-[.72rem] font-bold tracking-[.06em] text-cu-accent uppercase">September</span>
    <p class="mt-4 text-[clamp(2.6rem,5vw,3.4rem)] leading-none font-extrabold tracking-[-.035em] whitespace-nowrap text-cu-accent tabular-nums"><span data-count>2.7</span><span class="ml-0.5 text-[.55em] tracking-normal">M</span></p>
    <h3 class="mt-2 font-semibold">tasks ticked off</h3><p class="mt-1 text-[.84rem] text-[#475569]">Up from 2.1M in August, across 41 time zones.</p></article>
  <article class="relative overflow-hidden rounded-[18px] border border-cu-line bg-white px-6 pt-6 pb-[22px] shadow-[0_1px_2px_rgba(15,23,42,.04),0_12px_28px_-14px_rgba(67,56,202,.25)] before:absolute before:inset-x-0 before:top-0 before:h-1 before:bg-linear-to-r before:from-cu-accent before:to-[#7c3aed]"><span class="inline-block rounded-full bg-cu-soft px-2.5 py-[3px] text-[.72rem] font-bold tracking-[.06em] text-cu-accent uppercase">Per person</span>
    <p class="mt-4 text-[clamp(2.6rem,5vw,3.4rem)] leading-none font-extrabold tracking-[-.035em] whitespace-nowrap text-cu-accent tabular-nums"><span data-count>41</span><span class="ml-0.5 text-[.55em] tracking-normal"> min</span></p>
    <h3 class="mt-2 font-semibold">saved every week</h3><p class="mt-1 text-[.84rem] text-[#475569]">Median from time logs of 860 opted-in teams.</p></article>
  <article class="relative overflow-hidden rounded-[18px] border border-cu-line bg-white px-6 pt-6 pb-[22px] shadow-[0_1px_2px_rgba(15,23,42,.04),0_12px_28px_-14px_rgba(67,56,202,.25)] before:absolute before:inset-x-0 before:top-0 before:h-1 before:bg-linear-to-r before:from-cu-accent before:to-[#7c3aed]"><span class="inline-block rounded-full bg-cu-soft px-2.5 py-[3px] text-[.72rem] font-bold tracking-[.06em] text-cu-accent uppercase">Reviews</span>
    <p class="mt-4 text-[clamp(2.6rem,5vw,3.4rem)] leading-none font-extrabold tracking-[-.035em] whitespace-nowrap text-cu-accent tabular-nums"><span data-count>4.9</span><span class="ml-0.5 text-[.55em] tracking-normal">/5</span></p>
    <h3 class="mt-2 font-semibold">average rating</h3><p class="mt-1 text-[.84rem] text-[#475569]">From 3,100 verified reviews since 2023.</p></article>
</div></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#526075] uppercase">Counters inside a headline</span>
<div class="py-3 text-center">
  <h3 class="text-[clamp(1.6rem,4vw,2.6rem)] leading-[1.2] font-extrabold tracking-[-.03em] text-balance">Trusted by <span class="bg-[linear-gradient(transparent_68%,var(--color-cu-soft)_68%)] whitespace-nowrap text-cu-accent tabular-nums"><span data-count>2,140</span></span> agencies in <span class="bg-[linear-gradient(transparent_68%,var(--color-cu-soft)_68%)] whitespace-nowrap text-cu-accent tabular-nums"><span data-count>64</span></span> countries</h3>
  <p class="mx-auto mt-3 max-w-[52ch] text-[#475569]">From two-person studios in Porto to a 400-seat network in Singapore, teams run client work on Fieldnote.</p>
</div></div>
CSS
/* Count-up counters: one accent colour as a theme token; everything else is utilities in the markup. */
@theme { --color-cu-accent: #4338ca; --color-cu-soft: #eef2ff; --color-cu-line: #e2e8f0; }
JavaScript
// Count-up on view. The text inside each [data-count] element is the real, final number, so it shows as-is
// without JavaScript and under prefers-reduced-motion (no count from 0). While the digits run, an invisible copy
// of the final number keeps the width steady (digits grow from the right, next to any unit) and a visually
// hidden copy is what screen readers get.
// Optional: data-from="…" (start value, default 0) and data-duration="…" (ms, default 1800).
(() => {
  const els = document.querySelectorAll('[data-count]');
  if (!els.length || !('IntersectionObserver' in window) || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
  const lang = document.documentElement.lang || undefined;
  const sep = type => new Intl.NumberFormat(lang).formatToParts(12345.6).find(p => p.type === type)?.value;
  const group = sep('group') || ',', point = sep('decimal') || '.';
  const esc = s => s.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
  const numRe = new RegExp(`^(\\D*?)(\\d[\\d${esc(group)}]*(?:${esc(point)}\\d+)?)(.*)$`, 's');
  const ease = t => 1 - (1 - t) ** 4;
  const span = (css, text) => { const s = document.createElement('span'); s.style.cssText = css; s.textContent = text; return s; };
  const jobs = new WeakMap();
  const run = el => {
    const j = jobs.get(el), t0 = performance.now();
    const tick = now => {
      const p = Math.min((now - t0) / j.dur, 1);
      if (p < 1) { j.live.textContent = j.pre + j.fmt.format(j.from + (j.to - j.from) * ease(p)) + j.post; requestAnimationFrame(tick); }
      else { el.textContent = j.final; el.style.display = ''; }
    };
    requestAnimationFrame(tick);
  };
  const io = new IntersectionObserver(list => list.forEach(e => {
    if (e.isIntersecting) { io.unobserve(e.target); run(e.target); }
  }), { threshold: 0.6 });
  els.forEach(el => {
    const final = el.textContent, m = final.trim().match(numRe);
    if (!m) return;
    const [, pre, num, post] = m, dec = num.includes(point) ? num.split(point)[1].length : 0;
    const to = parseFloat(num.split(group).join('').replace(point, '.')), from = parseFloat(el.dataset.from) || 0;
    const fmt = new Intl.NumberFormat(lang, { minimumFractionDigits: dec, maximumFractionDigits: dec, useGrouping: num.includes(group) });
    const live = span('grid-area:1/1;justify-self:end', pre + fmt.format(from) + post);
    live.setAttribute('aria-hidden', 'true');
    el.style.display = 'inline-grid';
    el.replaceChildren(span('grid-area:1/1;visibility:hidden', final.trim()), live,
      span('position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap', final.trim()));
    jobs.set(el, { final, pre, post, to, from, fmt, live, dur: +el.dataset.duration || 1800 });
    io.observe(el);
  });
})();

About this set

Three ways to show numbers that count up when they scroll into view. Centred row puts four headline figures in a bordered strip with hairline dividers, for the band under a hero. Cards with accent numbers gives each figure a card, a period tag and a line of context, for a features or results section. Counters inside a headline runs the numbers inside a sentence such as Trusted by 2,140 agencies in 64 countries. The final number is written in the HTML, so the page is correct without JavaScript and in search results. A short vanilla script finds every element with data-count, reads its prefix, digits and decimal places, and counts from 0 to that value with requestAnimationFrame and an ease-out curve once an IntersectionObserver sees it. Intermediate values are formatted with Intl.NumberFormat, so 12,480 keeps its comma and 98.6 keeps one decimal throughout. An invisible copy of the final number holds the width so nothing shifts, and units such as $, %, M and + sit outside the counter so they keep their own colour.

What’s included

  • Count starts when the number scrolls into view, via IntersectionObserver
  • Intl.NumberFormat keeps separators and decimal places while it counts
  • Final value is plain text in the HTML, so it works without JavaScript
  • Reserved width stops the layout from jumping as digits are added
  • No count at all under prefers-reduced-motion

Accessibility

While a number counts, the visible digits are aria-hidden and a visually hidden copy holds the final value, so screen readers never read a half-counted number; when the count ends the element is put back exactly as it was. With prefers-reduced-motion the script does nothing and the final numbers show straight away. Units and labels are real text, and every colour pair meets WCAG AA contrast.

Customise it

Change --cu-accent and --cu-accent-soft on .cu for the colour, and add data-from or data-duration to any [data-count] element to change the start value or the length of the count. In Bootstrap the same variables sit on .cu; in Tailwind edit the --color-cu-* theme colours.