Four tiles on the home page showed numbers that counted up to a target value at the time. The animation worked from the first version, and for half a day I was convinced it was finished.
It was not. The count started when the intersection observer reported the tile — and that is not the same as “the tile is visible”.
Three different moments
Three events come into play with an animation like this, and they are easy to confuse, because on a developer's computer they usually happen almost at once.
1. the element enters the observed area (IntersectionObserver)
2. the element finishes fading in (opacity transition)
3. the element is in the visible frame (position relative to the window)On a phone these three moments drift apart by seconds. The tile enters the observed area with its bottom edge, when a dozen or so pixels of it are showing. It fades in with a delay, because it waits its turn in the entrance sequence. And it is really in frame only after scrolling.
The first version reacted to the first event. The counter ran all the way from zero to the result before the tile had a chance to appear — and by the time your eyes got there, a finished number was standing in it.
Fixing it by asking about the effect
The first reflex: duplicate in the number component the same delay logic the fade-in component has. A bad reflex — two places computing the same thing will drift apart at the first change.
Better to ask directly about the effect. The number component does not need to know when the fade-in will happen; it needs to know whether it already has.
const gdyWidoczna = () => {
const r = el.getBoundingClientRect();
const wEkranie = r.top < window.innerHeight * 0.92 && r.bottom > 0;
if (!wEkranie || krycieSkuteczne(el) < 0.9) {
czekanie = requestAnimationFrame(gdyWidoczna);
return;
}
// only here does the count start
};Two things surprised me while writing this condition.
First: **an element's own opacity is always one**. The transparency sits on the fading container, so you have to multiply the parents' values. Checking the element alone detects nothing — and looks correct.
Second: **the position condition matters here as much as opacity**. On the first paint, before the welcome screen gets its final height, the tiles sit higher than they will end up. The observer reports them straight away, quite rightly — only two frames later they are somewhere else.
The fuse
One more thing remained that is easy to forget, because it only shows up for someone else. If the number waits for visibility, a tile nobody scrolls to is left showing zero.
The server renders the target value — as it should, because without JavaScript, in a screen reader and for a search engine robot the number has to be there. But resetting it in the browser means that someone who does not scroll sees zero instead of the result.
Hence the fuse: after five seconds without entering the frame, the target value comes back and the observer is closed. **The number is content; the animation only reveals it.** Better to lose the effect than to show something untrue.
That, in fact, is a conclusion broader than this one counter. Every animation that swaps out content while it runs should have an answer to the question: what will someone see for whom this animation never starts.