Accordion expandWeb · App
- Effects
- 0
- Copies
- 0
- Stars
- 0
Target
Implement the "Stats count-up on enter" effect in vanilla HTML/CSS/JS. Context: A row of statistics counts from 0 to its value the first time it scrolls into view, each number offset slightly. Trigger: the element enters the viewport (first time only). Timeline: time-based (duration + easing). Motion tokens (normal): - number: 0 → target value - translateY (label): 8px → 0 - opacity (label): 0 → 1 - duration: 1200ms - easing: cubic-bezier(0.16, 1, 0.3, 1) - stagger: 120ms - note: number is text, updated with requestAnimationFrame; keep tabular figures to avoid jitter Motion tokens (prefers-reduced-motion): - number: target value → target value - opacity (label): 0 → 1 - duration: 0ms - note: show final values immediately; fade labels 200ms Technique: plain JavaScript (requestAnimationFrame / class toggles), no animation library required; GSAP (ScrollTrigger where relevant) is acceptable as an alternative. Acceptance: - animate only transform and opacity unless the spec lists other properties - no layout shift; content is visible without the animation - when prefers-reduced-motion is set, use the reduced tokens only - use font-variant-numeric: tabular-nums so the width does not jump - format with thousands separators on every frame
Generated from the spec. Prompts differ by target name only; the tokens are the source of truth.
demo.html
<div class="fx-count-up-on-enter" data-fx-root>
<dl class="fx-stats">
<div class="fx-stat"><dt>Effects</dt><dd class="fx-num" data-to="128">0</dd></div>
<div class="fx-stat"><dt>Copies</dt><dd class="fx-num" data-to="4820">0</dd></div>
<div class="fx-stat"><dt>Stars</dt><dd class="fx-num" data-to="960">0</dd></div>
</dl>
</div>
demo.css
.fx-count-up-on-enter { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.fx-count-up-on-enter .fx-stats { display: flex; gap: clamp(20px, 6%, 48px); margin: 0; }
.fx-count-up-on-enter .fx-stat { display: flex; flex-direction: column-reverse; align-items: center; gap: 4px; }
.fx-count-up-on-enter dt { font: 500 12px/1 Inter, system-ui, sans-serif; color: #71717a; opacity: 0; transform: translateY(8px); transition: opacity 400ms ease-out, transform 400ms cubic-bezier(.16, 1, .3, 1); }
.fx-count-up-on-enter dd { margin: 0; font: 600 clamp(26px, 5vw, 40px)/1 Inter, system-ui, sans-serif; font-variant-numeric: tabular-nums; color: var(--fx-ink, #18181b); }
.fx-count-up-on-enter.is-playing dt { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) { .fx-count-up-on-enter dt { transform: none; transition: opacity 200ms ease-out; } }
demo.js
const fmt = new Intl.NumberFormat('en-US');
const ease = (t) => 1 - Math.pow(1 - t, 4); // ≈ cubic-bezier(.16,1,.3,1)
export default function mount(root) {
const nums = Array.from(root.querySelectorAll('.fx-num'));
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
let raf = 0;
return {
play() {
cancelAnimationFrame(raf); root.classList.add('is-playing');
if (reduced) { nums.forEach((n) => (n.textContent = fmt.format(+n.dataset.to))); return; }
const t0 = performance.now();
const tick = (now) => {
let done = true;
nums.forEach((n, i) => {
const p = Math.min(1, Math.max(0, (now - t0 - i * 120) / 1200));
n.textContent = fmt.format(Math.round(ease(p) * +n.dataset.to));
if (p < 1) done = false;
});
if (!done) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
},
stop() { cancelAnimationFrame(raf); root.classList.remove('is-playing'); nums.forEach((n) => (n.textContent = '0')); },
};
}
demo.gsap.js
const fmt = new Intl.NumberFormat('en-US');
export default function mount(root) {
const nums = Array.from(root.querySelectorAll('.fx-num'));
let tween;
return {
play() {
const { gsap } = window; if (!gsap) { root.dataset.fxMode = 'gsap-missing'; return; }
root.dataset.fxMode = 'gsap'; root.classList.add('is-playing');
const state = nums.map(() => ({ v: 0 }));
tween = gsap.to(state, { v: (i) => +nums[i].dataset.to, duration: 1.2, ease: 'expo.out', stagger: 0.12, snap: { v: 1 }, onUpdate: () => state.forEach((s, i) => (nums[i].textContent = fmt.format(s.v))) });
},
stop() { tween?.kill(); tween = undefined; root.classList.remove('is-playing'); nums.forEach((n) => (n.textContent = '0')); },
};
}
Inspired by gsap.com — the classic GSAP snap counter. The preview is an original re-creation; the effect name belongs to its author.
Text can't be animated by CSS, so this is JS by nature. Tabular figures matter more than the easing: without them the row shakes on every digit change.
Added 2026-11-09 · Report an issue