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.
| Section | Band |
|---|---|
| Techniques | Scroll-driven |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
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.
More elements
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.