85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
284 lines
9.1 KiB
Markdown
284 lines
9.1 KiB
Markdown
---
|
||
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
|
||
<div
|
||
class="scene"
|
||
data-composition-id="counter-scene"
|
||
data-start="0"
|
||
data-duration="3"
|
||
data-track-index="0"
|
||
>
|
||
<div class="counter-wrap">
|
||
<span class="counter" id="counter">0</span><span class="counter-suffix">{suffix}</span>
|
||
</div>
|
||
<div class="counter-label">{label}</div>
|
||
</div>
|
||
```
|
||
|
||
## 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
|
||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||
<script>
|
||
window.__timelines = window.__timelines || {};
|
||
const tl = gsap.timeline({ paused: true });
|
||
|
||
const counter = document.getElementById("counter");
|
||
const state = { value: 0, fontSize: START_SIZE };
|
||
|
||
// Synchronized count + font-size tween
|
||
tl.to(
|
||
state,
|
||
{
|
||
value: TARGET_VALUE,
|
||
fontSize: END_SIZE,
|
||
duration: COUNT_DUR,
|
||
ease: COUNT_EASE,
|
||
onUpdate: () => {
|
||
counter.textContent = Math.round(state.value).toLocaleString();
|
||
counter.style.fontSize = `${state.fontSize}px`;
|
||
},
|
||
},
|
||
0,
|
||
);
|
||
|
||
// Suffix slides in after count completes
|
||
tl.to(
|
||
".counter-suffix",
|
||
{
|
||
opacity: 1,
|
||
y: 0,
|
||
duration: SUFFIX_DUR,
|
||
ease: `back.out(${SUFFIX_BOUNCE_FACTOR})`,
|
||
},
|
||
COUNT_DUR,
|
||
);
|
||
|
||
// Label fades in early
|
||
tl.from(
|
||
".counter-label",
|
||
{
|
||
opacity: 0,
|
||
y: 12,
|
||
duration: LABEL_DUR,
|
||
ease: "power2.out",
|
||
},
|
||
LABEL_AT,
|
||
);
|
||
|
||
window.__timelines["counter-scene"] = tl;
|
||
</script>
|
||
```
|
||
|
||
## 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
|