The bug report came in with a description that was already more precise than most: "it snaps instead of tweening." I was building a pre launch landing page as a classic WordPress theme, with anime.js 4.5.0 vendored as a UMD file and a single JS file holding every effect. All entrance animations were scroll triggered through IntersectionObserver. In my head the pattern was the tidy one: create every animation upfront with autoplay: false, then just call .play() when the section comes into view.
That pattern was exactly the problem.
The symptom: the elements arrive, but they never travel
What makes this one slippery is that it does not look like a broken animation. The elements do appear. The final opacity is right, the final position is right, nothing is missing or stuck offscreen. The only thing missing is the middle.
The moment a section entered the viewport, the text and cards were already sitting at their finished state. No fade, no rise from below, no easing. Zero milliseconds. If you scroll quickly you might even conclude it works. It is only when you scroll slowly that you notice there was never a transition at all.
My first suspects were the boring ones: a duration accidentally set to zero, a misspelled easing name, or a transition from the theme's CSS fighting the same properties. All clean. The durations were sane, the easing was valid, and nothing in CSS was contesting those properties.
The root cause: the clock is bound when the animation is created
anime.js v4 binds an animation to its engine clock at creation time, not when you call .play().
So the instant that animate(...) line executes, the animation already has a zero point on the global timeline. autoplay: false only says "do not render yet." It does not hold time still. Time keeps running while the visitor reads the hero, reads the next section, and so on.
By the time .play() finally fires because the section scrolled into view, the engine sees an animation with, say, an 800 ms duration whose zero point was more than ten seconds ago. From the engine's point of view the conclusion is reasonable: this animation is already finished. So it renders the last frame.
That is the snap. The easing did not fail. There was simply no duration left to tween through.
The reason this trips people up is muscle memory. In plenty of libraries, building an animation object and playing it later is completely normal. In v4, the gap between "created" and "played" is not neutral dead time. It counts.
Here is the shape that burns you:
const { animate } = window.anime;
// created once, up front, the first time the effects file runs
const reveal = animate('.section-copy', {
opacity: [0, 1],
translateY: [24, 0],
duration: 800,
ease: 'outQuad',
autoplay: false,
});
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) reveal.play(); // snaps immediately
});
}, { threshold: 0.2 });
io.observe(document.querySelector('.section-copy'));This code reads as correct. It even feels more efficient, since the animation object is only built once. The trouble is that the clock started ticking on the animate(...) line, and the visitor needs ten or more seconds to reach that section.
The fix: build the animation inside the callback, at trigger time
The rule turns out to be simple: never create an animation earlier than the moment you actually want it to run. Move the creation inside the IntersectionObserver callback, run it once, then release the observer.
The consequence is that the hidden pre state can no longer live on the animation object, because there is no animation object yet. I set that state as an inline style, synchronously, before the observer is attached.
const { animate } = window.anime;
const targets = document.querySelectorAll('[data-reveal]');
// 1. synchronous pre state via inline style, not via the animation object
targets.forEach((el) => {
el.style.opacity = '0';
el.style.transform = 'translateY(24px)';
});
let remaining = targets.length;
const io = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
// 2. the animation is built right here, as the element enters
animate(entry.target, {
opacity: [0, 1],
translateY: [24, 0],
duration: 800,
ease: 'outQuad',
});
// 3. fire once, then let go
observer.unobserve(entry.target);
remaining -= 1;
if (remaining === 0) observer.disconnect();
});
}, { threshold: 0.2 });
targets.forEach((el) => io.observe(el));Three things make this version correct:
The animation is created at trigger time. Its clock zero now coincides with the moment the element enters the viewport, so the full 800 ms is still available to tween through. No duration gets burned offscreen.
The pre state is a synchronous inline style. Since no animation exists before the trigger, something else has to hold the hidden state, and it has to apply immediately as the script runs so nothing flashes. There is a bonus here too: JavaScript is now what hides the element, so if the script never runs, the content simply stays visible instead of disappearing forever.
Fire once, then disconnect. Without unobserve, an element that leaves and re enters the viewport would spawn a fresh animation each time. Every new animation means a new zero point, which opens the door to a whole other category of weirdness you do not need.
Once it was in place I verified it directly against the live landing page with Playwright: the fade and rise play in full, the easing is visible, and nothing jumps to its final position anymore.
What I took away
- In anime.js v4,
autoplay: falseis not a pause button. The engine clock is bound the moment the animation is created, so delaying.play()just burns through the duration. - For scroll triggered animation, creation time and trigger time have to be the same moment. Build inside the callback, not at the top of the file.
- The hidden starting state is the job of a synchronous inline style, not of an animation object that does not exist yet.
- A snap to the end state almost never means your easing is wrong. It usually means the engine is convinced the animation already finished, and the right question is when its clock started ticking.