--- name: counting-dynamic-scale description: Counter animation where font size grows with the counting value, creating escalating visual weight. metadata: tags: counter, counting, scale, font-size, number, dynamic, emphasis --- # Counting with Dynamic Scale A number counts from A → B while its font size simultaneously grows, creating escalating visual weight that reinforces magnitude. ## How It Works A single eased timeline drives **two synchronized properties**: 1. The numeric value (rendered as DOM text via `onUpdate`) 2. The font size (tweened from `START_SIZE` → `END_SIZE`) As the number gets bigger, the text gets larger — visually communicating "this is impressive." ## Easing Pick by drama desired (the choice is discrete; coefficient is implicit): | GSAP ease | Effect | | ------------ | --------------------------------------------- | | `power1.out` | Mild — slight deceleration | | `power2.out` | Default — ease-out, fast start slow end | | `power3.out` | Strong — dramatic deceleration ⭐ recommended | | `expo.out` | Very dramatic — almost stops at the end | `power3.out` matches the polynomial `1 - (1-x)^k` family at k ≈ 2.5 — number rushes up then slows dramatically at the peak. ## HTML ```html
0{suffix}
{label}
``` ## CSS ```css .scene { position: relative; width: 100%; height: 100%; display: grid; place-items: center; background: {bgColor}; } .counter-wrap { display: flex; align-items: baseline; justify-content: center; gap: 8px; /* Fixed-width container prevents layout shift as digit count changes */ width: {counterContainerWidth}; text-align: center; } .counter { font-family: {font}; font-weight: 900; color: {textColor}; /* MANDATORY — tabular-nums keeps digits the same width */ font-variant-numeric: tabular-nums; /* Initial font-size; GSAP will tween this */ font-size: {startSize}; letter-spacing: -2px; line-height: 1; } .counter-suffix { font-family: {font}; font-weight: 800; color: {accentColor}; font-size: {suffixSize}; opacity: 0; transform: translateY(20px); } .counter-label { margin-top: 24px; font-family: {font}; font-size: {labelSize}; color: {mutedTextColor}; text-align: center; } ``` ## GSAP Timeline ```html ``` ## How to Choose Values - **TARGET_VALUE** — the number the counter lands on - Effects: 2–3 digits reads best at hero size; 4+ digits requires wider container - Constraints: must fit horizontally at END_SIZE inside the container - **START_SIZE / END_SIZE** — initial and final font size - Range: START_SIZE ≈ 40–60 % of END_SIZE - Effects: smaller START_SIZE = more dramatic growth; larger = subtler - Constraints: END_SIZE × digit count must fit the container width without clipping - **COUNT_DUR** — count + scale tween duration - Range: 1.2–2.5 s - Effects: shorter = aggressive; longer = settled, gives reading time - Constraints: must allow the eye to read the digits scrolling past; below ~0.8 s reads as a flash - **COUNT_EASE** — shared ease for value AND font-size - Discrete choice: `power2.out`, `power3.out`, `expo.out` (see table above) - Constraint: avoid `back.out` / `elastic.out` — overshoot reads as unstable data - **SUFFIX_DUR** — duration of the suffix slide-in - Range: 0.3–0.6 s - Effects: shorter = snap; longer = floats - Constraints: must fire after the count lands (started at COUNT_DUR), not during - **SUFFIX_BOUNCE_FACTOR** — back.out coefficient on the suffix entry - Range: 1.4–2.0 - Effects: 1.4 = small overshoot; 2.0 = bouncy - **LABEL_AT / LABEL_DUR** — when and how long the label fades in - Range: LABEL_AT < COUNT_DUR / 2 (label arrives before count peaks); LABEL_DUR 0.4–0.7 s ## Variations ### Direct `innerText` tween (no proxy object) The GSAP inspector reads `innerText` directly, so a number-only counter can skip the `state` proxy: ```js tl.to( counter, { innerText: TARGET_VALUE, duration: COUNT_DUR, ease: COUNT_EASE, snap: { innerText: 1 } }, 0, ); ``` `snap: { innerText: 1 }` keeps it integer. Keep the proxy-object `onUpdate` form (above) whenever you must **co-drive** font-size, locale formatting (`toLocaleString`), or a suffix in the same tween — `innerText` alone can't do those, and dynamic scale is the whole point of this rule, so the proxy form is the default here. ### 3D depth entry Combine with `translateZ` for parallax-style depth on entry: ```js tl.from( ".counter", { z: -300, duration: 0.6, ease: "power2.out", // requires parent or .counter itself to have perspective set }, 0, ); ``` CSS prerequisite: ```css .counter-wrap { perspective: 1000px; } .counter { transform-style: preserve-3d; } ``` ### Multi-stat coordinated reveal For 3 stats counting in parallel, share the SAME ease and duration so they finish together — visually a chord, not arpeggio. Each stat usually also needs a **paired graphic** (bar / ring / stars) — don't stop at the number; see [stat-bars-and-fills.md](stat-bars-and-fills.md): ```js ["#stat1", "#stat2", "#stat3"].forEach((sel, i) => { const obj = { v: 0 }; tl.to( obj, { v: TARGETS[i], duration: COUNT_DUR, ease: COUNT_EASE, onUpdate: () => (document.querySelector(sel).textContent = Math.round(obj.v)), }, 0, ); // same start position — chord }); ``` ## Key Principles - **Synchronized value + size in ONE tween** so they share an ease and stay coordinated - **`font-variant-numeric: tabular-nums` is mandatory** — without it digit-count transitions (e.g. 9 → 10 → 100) cause visible jitter as glyph widths change - **Fixed-width container** as belt-and-suspenders — even with tabular-nums, glyph shape changes can shift baselines - **Grow in place, don't bounce** — the number should feel weighty, not springy. `power3.out` ends at exact value; `back.out` overshoots and feels cartoonish - **Start small enough to grow noticeably** (~50 % of final size); end large enough to feel decisive but not clip viewport - **Suffix animates AFTER the count, not during** — gives the number its own beat - **❗ Label is BIG TEXT, not a page-style tiny caption** — for VIDEO, a small paragraph-style caption below a hero-size number reads as visual noise. Use display-size, uppercase, tracked label so the layout is "two-line big-text"; the label is part of the headline, not a footer. ## Critical Constraints - **`tabular-nums` mandatory** — required CSS for layout stability - **Timeline must be paused**: `gsap.timeline({ paused: true })`. Never `tl.play()` - **Registry key = `data-composition-id`**: `window.__timelines["counter-scene"]` must match scene root - **`onUpdate` mutates DOM**: HF runtime seeks the timeline frame-by-frame, so `onUpdate` runs on every seek call. Keep `onUpdate` work O(1) — set text + font-size, no DOM creation - **`Math.round` not `Math.floor`** — half-way through the final integer should display the final value briefly, not the previous one - **Avoid `back.out` / `elastic.out`** for the counter itself — overshoot makes the number look unstable (it's data, not decoration) ## Combinations - [stat-bars-and-fills.md](stat-bars-and-fills.md) — **the paired graphic beside the number** (growth bars / progress ring / star wipe). A stat scene is usually BOTH rules: the count-up here + a fill there. Give the fill the same ease and duration so number and graphic land as one beat. - [svg-path-draw.md](svg-path-draw.md) — icons drawing in around the number - [center-outward-expansion.md](center-outward-expansion.md) — related icons exploding outward synced to count peak ## Pairs with HF skills - `/hyperframes-animation` — timeline + `onUpdate` API - `/hyperframes-core` — composition wiring, `data-*` attributes - `/hyperframes-cli` — `hyperframes lint` to verify scene