Copied!
Band

Scrubbed stats band

A framed band that grows to full width while its numbers count up, tied to scroll position and reversible.

← Library

Element · Vanilla JS + CSSUpdated November 5, 2026

ScrollElementVanilla 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 "Scrubbed stats band" for my site using the Enrichr Library element at https://enrichr.dev/elements/scrub-stats-band.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/scrub-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:
- stats: 120+:Projects shipped, 98%:Clients who return, 14:Countries
- inset: 80
- radius: 28
- manual: 0
- frameColor: #c6f26b
- textColor: #10261f

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/scrub-stats-band.json.

Code

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

HTML

<section class="ef-stats">
  <div class="ef-stats__frame"><ul class="ef-stats__list"></ul></div>
</section>

CSS

.ef-stats { overflow: hidden; }
.ef-stats__frame { padding: 2.4rem 1.6rem; background: var(--ef-frame, #c6f26b); color: var(--ef-ink, #10261f); will-change: margin, border-radius; }
.ef-stats__list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 1.4rem; text-align: center; }
.ef-stats__n { display: block; font-size: clamp(2.2rem, 7vw, 4.4rem); font-weight: 800; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; line-height: 1; }
.ef-stats__l { display: block; margin-top: 0.4rem; font-size: 0.9rem; opacity: 0.8; }

JavaScript

// Scrubbed stats band: as the band scrolls into view, an inset coloured frame grows to full width and its numbers count up.
// It is scrubbed, not triggered: scrolling back reverses it. No animation library.
// mount(root, { stats ("120+:Projects, 98%:Retention"), inset (px), radius (px), manual (0 = follow scroll, 1-100 = fixed %), frameColor, textColor })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var sec = root.querySelector(".ef-stats");
  var frame = root.querySelector(".ef-stats__frame");
  var list = root.querySelector(".ef-stats__list");
  var opts = {}, items = [], raf = 0, visible = true, io = null, lastP = -1;

  function parse(str) {
    return str.split(",").map(function (chunk) {
      var parts = chunk.split(":");
      var m = /^(\d+(?:\.\d+)?)(.*)$/.exec((parts[0] || "").trim());
      return { n: m ? parseFloat(m[1]) : 0, suffix: m ? m[2] : "", label: parts.slice(1).join(":").trim() };
    }).filter(function (i) { return i.label || i.n; });
  }
  function render() {
    list.textContent = "";
    frame.style.setProperty("--ef-frame", opts.frameColor);
    frame.style.setProperty("--ef-ink", opts.textColor);
    items = parse(opts.stats).map(function (i) {
      var li = document.createElement("li");
      li.setAttribute("aria-label", i.n + i.suffix + " " + i.label);
      var n = document.createElement("span"); n.className = "ef-stats__n"; n.setAttribute("aria-hidden", "true");
      var l = document.createElement("span"); l.className = "ef-stats__l"; l.setAttribute("aria-hidden", "true"); l.textContent = i.label;
      li.appendChild(n); li.appendChild(l); list.appendChild(li);
      return { i: i, num: n };
    });
    lastP = -1;
    paint(true);
  }
  function progress() {
    if (reduce) return 1;
    if (opts.manual > 0) return opts.manual / 100;
    var r = sec.getBoundingClientRect(), vh = window.innerHeight || 800;
    return Math.min(1, Math.max(0, (vh * 0.95 - r.top) / (vh * 0.65)));
  }
  function paint(force) {
    var p = progress();
    if (!force && Math.abs(p - lastP) < 0.002) return;
    lastP = p;
    var e = p * p * (3 - 2 * p);
    var inset = (1 - e) * opts.inset;
    frame.style.margin = "0 " + inset.toFixed(1) + "px";
    frame.style.borderRadius = ((1 - e) * opts.radius).toFixed(1) + "px";
    items.forEach(function (it) {
      var v = it.i.n * e;
      it.num.textContent = (it.i.n % 1 ? v.toFixed(1) : Math.round(v)) + it.i.suffix;
    });
  }
  function tick() { raf = requestAnimationFrame(tick); if (visible) paint(false); }
  function update(next) {
    opts = Object.assign({ stats: "120+:Projects shipped, 98%:Clients who return, 14:Countries", inset: 80, radius: 28, manual: 0, frameColor: "#c6f26b", textColor: "#10261f" }, opts, next || {});
    render();
  }
  update(o);
  if (!reduce) {
    if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(sec); }
    raf = requestAnimationFrame(tick);
  }
  return { update: update, destroy: function () { cancelAnimationFrame(raf); if (io) io.disconnect(); } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"stats":"120+:Projects shipped, 98%:Clients who return, 14:Countries","inset":80,"radius":28,"manual":0,"frameColor":"#c6f26b","textColor":"#10261f"});

How it works

Scroll progress is the band's position as it moves up the viewport, from 0 to 1 over about two thirds of a screen. A smoothstep curve turns that into an ease value. The frame's side margins and corner radius shrink from their starting values to zero by that value, and each number is its target times the same value, so the numbers count up as the frame opens. It is scrubbed, not triggered, so scrolling back plays it in reverse.

Accessibility and performance

Each stat has a complete label for screen readers and the counting digits are hidden from them, so nothing announces 'one, two, three' while counting. With reduced motion the frame is full width and the numbers show their final values.

One bounding-rect read per frame while on screen, and writes only when progress changed. Margin and radius change layout slightly, so keep the band's content simple.

SectionBand
TechniquesScroll-driven
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested

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.

Word read highlightWords in a paragraph light up one by one as it scrolls through the viewport. Scroll velocity skewText that leans and splits into two colour fringes in proportion to how fast the page scrolls. Scroll spring headingAn oversized heading slides sideways with scroll progress through a spring, lagging and settling.

FAQ

Why scrub instead of trigger once?

A scrubbed value always matches where the visitor is on the page, so it never fires late or out of order, and it works in both directions.

Does the count-up work for decimals?

Yes. A target with a decimal shows one decimal place while counting. Suffixes such as +, % or k are kept.

Can I use it without a frame?

Set the starting inset and radius to zero and only the count-up remains.