Copied!
Band

Counting stats band

A stats band whose numbers count up from zero, rules draw under them and labels fade in one after another when it comes into view.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

TextElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Counting stats band" for my site using the Enrichr Library element at https://enrichr.dev/elements/counting-stats-band.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/counting-stats-band.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- eyebrow: Status: Shipping
- title: Small files, big motion
- s1Tag: 01 / Frame rate
- s1Number: 60
- s1Unit: fps
- s1Label: The frame rate every animation here is tuned to hold
- s2Tag: 02 / Files
- s2Number: 3
- s2Unit: files
- s2Label: Markup, styles and a script. Nothing else to install
- s3Tag: 03 / Vanilla
- s3Number: 100
- s3Unit: %
- s3Label: Plain JavaScript that drops into React, Next.js or a static page
- speed: 1
- background: #fbf8f1
- panel: #ffffff
- text: #0c1e18
- accent: #126e45

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/counting-stats-band.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-csb">
  <div class="ef-csb__grid" aria-hidden="true"></div>
  <div class="ef-csb__inner">
    <div class="ef-csb__eyebrow"><span class="ef-csb__dot" aria-hidden="true"></span><span class="ef-csb__et">Status: Shipping</span></div>
    <h2 class="ef-csb__title">Small files, big motion</h2>
    <ul class="ef-csb__stats"></ul>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-csb { --ef-csb-bg: #fbf8f1; --ef-csb-panel: #ffffff; --ef-csb-text: #0c1e18; --ef-csb-acc: #126e45; --ef-csb-line: color-mix(in srgb, var(--ef-csb-text) 13%, var(--ef-csb-bg)); --ef-csb-dim: color-mix(in srgb, var(--ef-csb-text) 62%, var(--ef-csb-bg)); position: relative; width: 100%; box-sizing: border-box; min-height: 24rem; padding: 5rem 1.5rem 6rem; overflow: hidden; border-radius: 1rem; background: var(--ef-csb-bg); color: var(--ef-csb-text); font-family: 'Parkinsans', 'Space Grotesk', sans-serif; container-type: inline-size; }
.ef-csb *, .ef-csb *::before, .ef-csb *::after { box-sizing: border-box; }
.ef-csb .ef-csb__grid { position: absolute; inset: 0; background-image: linear-gradient(var(--ef-csb-line) 1px, transparent 1px), linear-gradient(90deg, var(--ef-csb-line) 1px, transparent 1px); background-size: 64px 64px; opacity: 0.55; -webkit-mask-image: radial-gradient(ellipse 80% 60% at 50% 20%, #000 20%, transparent 75%); mask-image: radial-gradient(ellipse 80% 60% at 50% 20%, #000 20%, transparent 75%); pointer-events: none; }
.ef-csb .ef-csb__inner { position: relative; max-width: 1080px; margin: 0 auto; }
.ef-csb .ef-csb__eyebrow { display: flex; align-items: center; gap: 0.6rem; margin: 0 0 1.25rem; font-family: 'Fira Code', ui-monospace, monospace; font-size: 0.75rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ef-csb-acc); opacity: 0; }
.ef-csb .ef-csb__dot { width: 7px; height: 7px; flex: none; border-radius: 50%; background: var(--ef-csb-acc); box-shadow: 0 0 0 0 color-mix(in srgb, var(--ef-csb-acc) 60%, transparent); }
.ef-csb .ef-csb__title { margin: 0 0 3.5rem; padding: 0; max-width: 14ch; color: inherit; font-family: inherit; font-weight: 800; font-size: clamp(2.1rem, 5cqw, 3.6rem); line-height: 1.02; letter-spacing: -0.02em; text-transform: none; opacity: 0; }
.ef-csb .ef-csb__stats { display: grid; grid-template-columns: repeat(3, 1fr); margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--ef-csb-line); border-left: 1px solid var(--ef-csb-line); }
.ef-csb .ef-csb__stat { position: relative; margin: 0; padding: 2.4rem 1.8rem 2.2rem; list-style: none; opacity: 1; border-right: 1px solid var(--ef-csb-line); border-bottom: 1px solid var(--ef-csb-line); background: var(--ef-csb-panel); }
.ef-csb .ef-csb__idx { margin: 0 0 1.6rem; font-family: 'Fira Code', ui-monospace, monospace; font-size: 0.7rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ef-csb-dim); opacity: 0; }
.ef-csb .ef-csb__num { display: flex; align-items: baseline; gap: 0.15em; margin: 0; font-family: 'Fira Code', ui-monospace, monospace; font-weight: 700; font-size: clamp(2.4rem, 4.4cqw, 3.4rem); line-height: 1; white-space: nowrap; color: var(--ef-csb-text); }
.ef-csb .ef-csb__unit { font-size: 0.42em; font-weight: 600; color: var(--ef-csb-acc); }
.ef-csb .ef-csb__pre { margin-right: -0.05em; }
.ef-csb .ef-csb__line { width: 0%; height: 2px; margin: 1.1rem 0 1.3rem; background: var(--ef-csb-acc); }
.ef-csb .ef-csb__label { max-width: 26ch; margin: 0; padding: 0; font-family: 'Instrument Sans', system-ui, sans-serif; font-size: 0.95rem; line-height: 1.45; color: var(--ef-csb-dim); opacity: 0; }
.ef-csb .ef-csb__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@container (max-width: 712px) { .ef-csb .ef-csb__stats { grid-template-columns: 1fr; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Counting stats band: a status line with a pulsing dot, a heading and three stat cells. When the band first comes into
// view, one timeline plays: the status line fades in, the heading fades up 16px, each cell's tag and then its label fade
// in 0.15 s apart, the numbers count up from zero over 1.8 s on a quartic in-out curve (GSAP power3.inOut) and a rule
// under each number draws across on a cubic out curve. The dot's ring swells and fades every 1.4 s, for as long as the
// band is on screen. Numbers keep any prefix (like $) small and any suffix (like k or +) at full size.
// Reduced motion shows the finished band at once, with no counting and no pulse. Paused off screen and in hidden tabs.
// mount(root, { eyebrow, title, s1Tag, s1Number, s1Unit, s1Label, s2..., s3..., speed, background, panel, text, accent })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-csb");
  var eyebrow = root.querySelector(".ef-csb__eyebrow");
  var eyeText = root.querySelector(".ef-csb__et");
  var dot = root.querySelector(".ef-csb__dot");
  var title = root.querySelector(".ef-csb__title");
  var list = root.querySelector(".ef-csb__stats");
  var opts = {}, stats = [], sig = "", raf = 0, io = null, visible = false, started = false, destroyed = false, clock = 0, last = 0, acc = [18, 110, 69];
  var END = 2.5; // the last tween (third count) ends at 0.4 + 0.3 + 1.8 s

  // GSAP's eases: power1 = quad, power2 = cubic, power3 = quartic
  function out2(p) { return 1 - (1 - p) * (1 - p); }
  function out3(p) { return 1 - Math.pow(1 - p, 3); }
  function out4(p) { return 1 - Math.pow(1 - p, 4); }
  function inOut4(p) { return p < 0.5 ? Math.pow(p * 2, 4) / 2 : 1 - Math.pow((1 - p) * 2, 4) / 2; }
  function prog(t, start, dur) { var p = (t - start) / dur; return p <= 0 ? 0 : p >= 1 ? 1 : p; }
  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function hex(h) { var m = /^#?([0-9a-f]{6})$/i.exec(h || ""); var v = parseInt(m ? m[1] : "126e45", 16); return [v >> 16, (v >> 8) & 255, v & 255]; }

  // "$50k" -> small "$", count to 50, then "k" at full size
  function parse(s) {
    var m = /^([^0-9.\-]*)(-?\d*\.?\d+)(.*)$/.exec(String(s).trim());
    if (!m) return { pre: "", target: 0, dec: 0, suf: String(s), fixed: true };
    return { pre: m[1].trim(), target: parseFloat(m[2]), dec: (m[2].split(".")[1] || "").length, suf: m[3] };
  }
  function el(tag, cls, text) { var e = document.createElement(tag); e.className = cls; if (text != null) e.textContent = text; return e; }
  function build() {
    list.textContent = ""; stats = [];
    for (var i = 1; i <= 3; i++) {
      var n = parse(opts["s" + i + "Number"]), unit = String(opts["s" + i + "Unit"] || "");
      var li = el("li", "ef-csb__stat");
      var idx = li.appendChild(el("div", "ef-csb__idx", opts["s" + i + "Tag"]));
      var row = li.appendChild(el("div", "ef-csb__num")); row.setAttribute("aria-hidden", "true");
      if (n.pre) row.appendChild(el("span", "ef-csb__unit ef-csb__pre", n.pre));
      var count = row.appendChild(el("span", "ef-csb__count", n.fixed ? n.suf : "0" + n.suf));
      if (unit) row.appendChild(el("span", "ef-csb__unit", unit));
      li.appendChild(el("span", "ef-csb__sr", String(opts["s" + i + "Number"]) + (unit ? " " + unit : "")));
      var line = li.appendChild(el("div", "ef-csb__line"));
      var label = li.appendChild(el("p", "ef-csb__label", opts["s" + i + "Label"]));
      list.appendChild(li);
      stats.push({ n: n, idx: idx, count: count, line: line, label: label, shown: "" });
    }
  }
  function fmt(n, v) { return (n.dec ? v.toFixed(n.dec) : String(Math.round(v))) + n.suf; }

  // the whole band at time t (seconds since it first came into view)
  function render(t) {
    eyebrow.style.opacity = out3(prog(t, 0, 0.5));
    var e = out4(prog(t, 0.3, 0.7));
    title.style.opacity = e; title.style.transform = "translateY(" + (16 * (1 - e)).toFixed(2) + "px)";
    for (var i = 0; i < stats.length; i++) {
      var s = stats[i];
      s.idx.style.opacity = out3(prog(t, 0.7 + 0.15 * i, 0.4));
      s.label.style.opacity = out3(prog(t, 1.2 + 0.15 * i, 0.5));
      s.line.style.width = (100 * out3(prog(t, 0.5 + 0.15 * i, 1.4))).toFixed(2) + "%";
      if (!s.n.fixed) {
        var txt = fmt(s.n, s.n.target * inOut4(prog(t, 0.4 + 0.15 * i, 1.8)));
        if (txt !== s.shown) { s.count.textContent = txt; s.shown = txt; }
      }
    }
    // the dot's ring: spread 0 -> 6px while it fades from 60% to clear, quad out, every 1.4 s
    if (reduce) { dot.style.boxShadow = "none"; return; }
    var q = out2((t % 1.4) / 1.4);
    dot.style.boxShadow = "0 0 0 " + (6 * q).toFixed(2) + "px rgba(" + acc[0] + "," + acc[1] + "," + acc[2] + "," + (0.6 * (1 - q)).toFixed(3) + ")";
  }
  function loop(now) {
    raf = requestAnimationFrame(loop);
    var dt = (now - last) / 1000; last = now;
    if (dt > 0.5 || dt < 0) dt = 0.033; // like GSAP's lag smoothing after a long stall
    if (!visible || document.hidden) return;
    clock += dt * opts.speed;
    render(clock);
  }
  function start() {
    if (started || destroyed) return;
    started = true;
    if (reduce) { render(END); return; }
    last = performance.now(); raf = requestAnimationFrame(loop);
  }

  function update(partial) {
    opts = Object.assign({
      eyebrow: "Status: Shipping", title: "Small files, big motion",
      s1Tag: "01 / Frame rate", s1Number: "60", s1Unit: "fps", s1Label: "The frame rate every animation here is tuned to hold",
      s2Tag: "02 / Files", s2Number: "3", s2Unit: "files", s2Label: "Markup, styles and a script. Nothing else to install",
      s3Tag: "03 / Vanilla", s3Number: "100", s3Unit: "%", s3Label: "Plain JavaScript that drops into React, Next.js or a static page",
      speed: 1, background: "#fbf8f1", panel: "#ffffff", text: "#0c1e18", accent: "#126e45"
    }, opts, partial || {});
    opts.speed = Math.max(0.05, num(opts.speed, 1));
    eyeText.textContent = opts.eyebrow;
    title.textContent = opts.title;
    var nsig = [1, 2, 3].map(function (i) { return [opts["s" + i + "Tag"], opts["s" + i + "Number"], opts["s" + i + "Unit"], opts["s" + i + "Label"]].join("\u0001"); }).join("\u0002");
    if (nsig !== sig) { sig = nsig; build(); }
    box.style.setProperty("--ef-csb-bg", opts.background);
    box.style.setProperty("--ef-csb-panel", opts.panel);
    box.style.setProperty("--ef-csb-text", opts.text);
    box.style.setProperty("--ef-csb-acc", opts.accent);
    acc = hex(opts.accent);
    render(started ? (reduce ? END : clock) : 0);
  }

  update(o);
  if (reduce || !("IntersectionObserver" in window)) { visible = true; start(); }
  else {
    // start once a fifth of the band, or 160px of it, is on screen (a tall band inside a small preview never reaches a fifth)
    io = new IntersectionObserver(function (en) { var e = en[0]; visible = e.isIntersecting; if (visible && (e.intersectionRatio >= 0.2 || e.intersectionRect.height >= 160)) start(); }, { threshold: [0, 0.05, 0.1, 0.2] });
    io.observe(box);
  }
  return {
    update: update,
    destroy: function () { destroyed = true; cancelAnimationFrame(raf); if (io) io.disconnect(); }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"eyebrow":"Status: Shipping","title":"Small files, big motion","s1Tag":"01 / Frame rate","s1Number":"60","s1Unit":"fps","s1Label":"The frame rate every animation here is tuned to hold","s2Tag":"02 / Files","s2Number":"3","s2Unit":"files","s2Label":"Markup, styles and a script. Nothing else to install","s3Tag":"03 / Vanilla","s3Number":"100","s3Unit":"%","s3Label":"Plain JavaScript that drops into React, Next.js or a static page","speed":1,"background":"#fbf8f1","panel":"#ffffff","text":"#0c1e18","accent":"#126e45"});

How it works

When a fifth of the band is in view, one timeline starts. The status line fades in over half a second, the heading fades and rises 16px on a quartic ease-out, each stat's tag fades in 0.15 seconds after the last, then each label does the same. The numbers count from zero to their targets over 1.8 seconds on a quartic in-out curve (GSAP's power3.inOut), 0.15 seconds apart, and a rule draws across under each one over 1.4 seconds. The status dot sends out a ring every 1.4 seconds that grows to 6px while it fades. A number like $50k keeps the $ small in the accent colour, counts the 50 and keeps the k at full size, so prices, percentages and plus signs all work.

Accessibility and performance

With prefers-reduced-motion the finished band shows at once: no fades, no counting and no pulsing ring. Each stat has its full value as screen reader text, and the counting digits are hidden from assistive technology so nobody hears numbers ticking. The stats are a real list. Nothing needs keyboard input.

Per frame the timeline sets a few opacities, transforms and widths and changes a number's text only when the rounded value changes. After the intro only the small dot ring updates, and the loop stops while the band is off screen or the tab is hidden.

SectionBand
TechniquesStagger, Reveal and intro, Text animation
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byThe Retention Crisis by Enrichr on CodePen. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Flight path heroA photo hero with a gradient headline that drops in letter by letter, a plane flying a soft contrail, and a menu button that wipes a full-screen menu down while the page swings away. Hover lift typeBig uppercase type that rises into place letter by letter, then lifts and changes colour under the pointer, word by word or letter by letter. Kinetic type experimentsFive experimental text studies: a breathing variable weight, matrix rain that locks into your word, a blurred word morph, a scroll-linked colour split and a gravity drop.

FAQ

Can I use decimals?

Yes. Type a number like 4.9 and it counts with one decimal place. Whole numbers count in whole steps, as in the original.

Does it replay when I scroll back?

No, the intro plays once, the first time the band comes into view. Reload or remount it to play it again.

Does it need GSAP?

No. The timeline and the quartic and cubic eases are a few lines of plain JavaScript that copy GSAP's own curves.

How is it different from the scrub stats band?

The scrub stats band follows your scroll position. This one plays a timed intro once and leaves the numbers in place.