MUST

·

Scroll · numbers

Counters & stat blocks

Every site has a stats band, and almost every counter you have seen has the same two faults. The digits jitter horizontally as they count, because the font uses proportional figures where 1 is narrower than 8. And the number is written with toFixed() or raw textContent, so a value that crosses 999 suddenly grows a comma and the whole row shifts. Both fixes are one line each — font-variant-numeric: tabular-nums and a single Intl.NumberFormat with the decimal count pinned. What is left is a tween on a plain object, which is the correct mental model: GSAP animates a number, and you write that number to the DOM once per frame.

Open the live demo →

How to build

Script only

Free plan gets you part of the way

The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.

How the two routes work →

Plugins

ScrollTrigger

Build time

20 min, and it covers every stat block you will ever build.

No-code?

No. Webflow's GSAP panel animates properties, not text content — there is no number-to-text binding in the UI. This one is always code.

01

Webflow structure

element · class · attribute

<b>Section</b> <i>section_stats</i> <b>Div</b> <i>stats_grid</i> <b>Div</b> <i>stats_item</i> <b>Text</b> <i>stats_number</i> <u>data-count="4120"</u> <b>Text</b> <i>stats_label</i> <b>Paragraph</b> <i>stats_note</i> <b>Div</b> <i>stats_item</i> <b>Text</b> <i>stats_number</i> <u>data-count="61" data-count-suffix="%"</u> <b>Div</b> <i>stats_item</i> <b>Text</b> <i>stats_number</i> <u>data-count="4.2" data-count-decimals="1" data-count-prefix="£" data-count-suffix="m"</u> <b>Div</b> <i>bar_row</i> <b>Text</b> <i>bar_label</i> <b>Div</b> <i>bar_track</i> <b>Div</b> <i>bar_fill</i> <u>data-bar="61"</u> <b>Text</b> <i>bar_value</i> <u>data-count="61" data-count-suffix="%"</u>

02

Styles to set in the Designer

stats_number | <code>font-variant-numeric: tabular-nums</code>. Non-negotiable. Without it every digit change resizes the string and the number visibly twitches — the single thing that makes a counter look amateur. stats_number | Type the <strong>final</strong> value as the placeholder text in the Designer, not <code>0</code>. If JS never runs, the visitor sees the real figure rather than a wall of zeroes. bar_track | 3px tall, a faint background, <code>position: relative</code>. bar_fill | <code>position: absolute; inset: 0; transform-origin: left;</code> — <code>scaleX</code>, never <code>width</code>. Width animates layout on every frame; scaleX is composited. bar_value | Also <code>tabular-nums</code>, and give it a fixed width so the bar cannot shift as the number grows.

03

Building it with the Interactions panel

no code, free plan

What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.

See “The script” below for the exact code.

04

Custom attributes

Element settings → Custom attributes

Any text element | data-count | 4120 | The target. Everything else is optional. Number | data-count-decimals | 1 | Fixed decimal places. Pinning both min and max is what stops the string width changing. Number | data-count-prefix | £ | Sits before the figure. Keep currency out of the number itself. Number | data-count-suffix | % / m / + | Sits after. Renders as part of the same text node so it never wraps away from the number. Number | data-count-duration | 2.2 | Default 1.6s. Give genuinely big numbers longer — a five-digit figure landing in the same time as a two-digit one reads as a slot machine. bar_fill | data-bar | 61 | Percentage, as a plain number. The script converts it to a scaleX.

05

CSS

Page settings → Inside <head> tag

Inside <head> tag

Copy

06

The script

Page settings → Before </body> tag

Before </body> tag

Copy

<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { gsap.registerPlugin(ScrollTrigger); const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; document.querySelectorAll("[data-count]").forEach(el => { const target = parseFloat(el.dataset.count) || 0; const decimals = parseInt(el.dataset.countDecimals || "0", 10); const prefix = el.dataset.countPrefix || ""; const suffix = el.dataset.countSuffix || ""; // Build the formatter ONCE, outside the tween. Pinning min and max // fraction digits is what keeps the string width constant. const fmt = new Intl.NumberFormat("en-GB", { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); const write = v => el.textContent = prefix + fmt.format(v) + suffix; if (reduced) { write(target); return; } write(0); const state = { v: 0 }; // GSAP animates the OBJECT, not the DOM gsap.to(state, { v: target, duration: parseFloat(el.dataset.countDuration || "1.6"), ease: "power2.out", // fast, then settles — never "linear" onUpdate: () => write(state.v), // one DOM write per frame, no more scrollTrigger: { trigger: el, start: "top 88%", once: true } }); }); document.querySelectorAll("[data-bar]").forEach(fill => { const pct = (parseFloat(fill.dataset.bar) || 0) / 100; gsap.set(fill, { scaleX: reduced ? pct : 0 }); if (reduced) return; gsap.to(fill, { scaleX: pct, duration: 1.4, ease: "expo.out", scrollTrigger: { trigger: fill, start: "top 92%", once: true } }); }); }); </script>

07

Gotchas that will cost you an hour

  1. tabular-nums or it twitches. Most webfonts ship proportional figures by default. Watch a counter run past 111 → 888 without it and you will never un-see the wobble.
  2. Put the final number in the Designer, not 0. The script overwrites it on the first frame anyway, and if the script is ever blocked the client's homepage shows real figures instead of a row of zeroes.
  3. Animate an object, never the element. gsap.to(el, { innerText: 500 }) looks tempting and is a trap — it re-parses the DOM string every frame and you lose all control over formatting.
  4. Build the formatter outside the tween. A new Intl.NumberFormat per frame is one of the more expensive things you can do in an onUpdate, and it is invisible until someone opens the page on a five-year-old phone.
  5. once: true. Counters that re-run every time the section scrolls back into view are the fastest way to make a stats band feel like a toy.
  6. Longer durations for bigger numbers. 1.6s suits two or three digits. Five digits needs 2.2s or it reads as a fruit machine rather than as a measurement.
08

Push it further

  • Add data-count-from so a figure can count down — “from 14,000 to 0” is a stronger story than counting up to a saving.
  • Drive the bar's scaleX and the number from a single timeline so the value can never disagree with the bar mid-animation.
  • Feed the same object into a DrawSVG arc for a radial progress dial — the SVG-draw lesson has the plugin setup.

Webflow Motion Lab — GSAP 3.15, all plugins free.

2

/ 30