Files
zooqueen 62bbc7766c skills: give each skill the directory its name declares
Every SKILL.md already declared the new name — name: frames, frames-cli,
frames-core, and so on — but the directories were never moved, so the tree
carried frames-* in content and hyperframes-* on disk. Everything that
resolves a skill by path missed: the core-skill list, the plugin marketplace
pin, and the docs the CLI reads at runtime. Eight tests failed on main because
the files they open do not exist.

The directories now match what the files say they are, and the guide the
claude-design contract reads takes the name that contract already used.
2026-08-13 18:17:24 -07:00

5.8 KiB
Raw Permalink Blame History

name, description, metadata
name description metadata
counting-dynamic-scale Counter animation where the value counts up while transform scale grows to its final size, creating escalating visual weight without per-frame text reflow.
tags
counter, counting, scale, transform, number, dynamic, emphasis

Counting with Dynamic Scale

A number counts from A → B while its transform scale grows to the final size — escalating visual weight ("this is impressive") without tweening font-size or forcing text layout on every frame. The final font size is static CSS; only the transform changes.

How It Works

Two synchronized tweens at the SAME timeline position with the SAME ease: (1) a proxy value rendered as text via onUpdate (Math.round(...).toLocaleString()), (2) the counter's transform scale: START_SCALE → 1, where START_SCALE = START_SIZE / END_SIZE. A suffix (%, ×, +) slides in AFTER the count lands — the number gets its own beat — and a label fades in early.

Recipe

<!-- inside a standard scene clip (frames-core) -->
<div class="counter-wrap">
  <span class="counter" id="counter">0</span><span class="counter-suffix">{suffix}</span>
</div>
<div class="counter-label">{label}</div>
.counter-wrap {
  display: flex;
  align-items: baseline;
  justify-content: center;
  width: {counterContainerWidth}; /* fixed width — no layout shift as digit count changes */
}
.counter {
  font-variant-numeric: tabular-nums; /* MANDATORY — digits keep equal width */
  display: inline-block;
  font-size: {endSize}; /* final size is static; GSAP animates scale, not font-size */
  transform-origin: center center;
}
.counter-suffix {
  opacity: 0;
  transform: translateY(20px);
}
const counter = document.getElementById("counter");
const state = { value: 0 };
const START_SCALE = START_SIZE / END_SIZE;

// Count value — onUpdate changes text only
tl.to(
  state,
  {
    value: TARGET_VALUE,
    duration: COUNT_DUR,
    ease: COUNT_EASE,
    onUpdate: () => {
      counter.textContent = Math.round(state.value).toLocaleString();
    },
  },
  0,
);

// Visual growth — compositor transform sharing the count's timing + ease
tl.fromTo(counter, { scale: START_SCALE }, { scale: 1, duration: COUNT_DUR, ease: COUNT_EASE }, 0);

// Suffix slides in AFTER the 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);

Variations

  • Direct innerText tween (no proxy) — GSAP can tween innerText directly for a number-only counter; keep the proxy form when you need locale formatting or suffix logic. The scale tween stays separate either way:
tl.to(
  counter,
  { innerText: TARGET_VALUE, duration: COUNT_DUR, ease: COUNT_EASE, snap: { innerText: 1 } },
  0,
);
  • 3D depth entry — add a tl.from(".counter", { z: -300, ... }, 0) push-in; requires perspective on .counter-wrap and transform-style: preserve-3d on the counter.
  • Multi-stat coordinated reveal — 3 stats counting in parallel share the SAME ease, duration, and start position so they finish together (a chord, not an arpeggio). Each stat usually also needs a paired graphic (bar / ring / stars) — don't stop at the number; see stat-bars-and-fills.md.

Values

token range notes
TARGET_VALUE 2–3 digits ideal 4+ digits needs a wider container; must fit at END_SIZE without clipping
START_SIZE / END_SIZE START ≈ 40–60% of END design inputs used once for START_SCALE; never tween either
COUNT_DUR 1.2–2.5s below ~0.8s reads as a flash — the eye must read the digits scrolling past
COUNT_EASE power2.out / power3.out ⭐ / expo.out shared by value + scale; more .out = more dramatic deceleration at the peak
SUFFIX_DUR 0.3–0.6s fires at COUNT_DUR, never during the count
SUFFIX_BOUNCE_FACTOR 1.4–2.0 overshoot is fine on the suffix (it's punctuation, not data)
LABEL_AT / LABEL_DUR AT < COUNT_DUR/2; 0.4–0.7s label arrives before the count peaks

Critical Constraints

  • tabular-nums mandatory + fixed-width container as belt-and-suspenders — without them digit-count transitions (9 → 10 → 100) jitter as glyph widths change.
  • Never set fontSize in onUpdate — final type size is static CSS; only the transform changes per frame. Keep onUpdate O(1): set text only, no style writes or DOM creation.
  • Math.round, not Math.floor — halfway through the final integer should already display the final value.
  • Avoid back.out / elastic.out on the counter itself — overshoot makes the number look unstable (it's data, not decoration). Grow in place, don't bounce.
  • Label is BIG TEXT, not a page-style caption — a tiny paragraph under a hero-size number reads as visual noise in video. Display-size, uppercase, tracked: the label is part of the headline.

See also

stat-bars-and-fills (the paired graphic — give it the same ease/duration so number and fill land as one beat) · svg-path-draw (icons drawing in around the number) · center-outward-expansion (icons bursting outward at the count peak).