I was verifying the motion layer of a landing page through Playwright MCP, run against a local WordPress install on my own machine, the Studio kind that keeps its database in SQLite. Two layers were moving on that page: anime.js tweens for the entrance animations, and Lenis smooth scroll running on a manual rAF loop. Practically everything that moved there hung off one and the same source, requestAnimationFrame.
Through the Chromium window Playwright was driving, the tweens were choppy. Not slightly less smooth, but stuttering like an animation that had most of its frames thrown away somewhere along the line. I had already started assembling the usual list of suspects in my head: wrong easing, a duration stretched too long, trigger ordering that overlapped.
The same page in a normal browser, perfectly smooth
Before opening the animation file I opened the same page in a normal browser, focused and sitting right in front of me. There the animation was perfectly smooth. Not a little better, the symptom simply was not there at all.
That moves the question somewhere else. Both attempts ran identical code against the exact same page, so the difference cannot possibly live inside that code. The only thing that changed was who was holding the window, and whether that window was visible.
What got throttled was not the animation but the frame source
requestAnimationFrame in an occluded Playwright automation window, meaning one that is not visible because another window covers it or because it sits in the background, gets throttled by the browser down to roughly one frame per second.
Put that number next to how the page actually works and the symptom stops being mysterious. Lenis on that landing ran with a lerp of about 0.11 on a manual rAF loop, so each frame closes 11 percent of the remaining distance and leaves 0.89 of what was left before. If there is only one frame per second, the arithmetic is easy to repeat yourself: after one second 0.89 of the distance remains, and after five seconds it is 0.89 to the fifth power, roughly 0.558. More than half of the scroll journey is still outstanding after five full seconds. To the eye that is not smooth scroll, that is a page crawling, stopping, then crawling again.
The note I left behind at the time pushed the conclusion further, saying that any rAF-driven tween will look janky in automation even when the code is fine. I wrote that down as a hunch rather than as something I measured case by case, because the only thing I genuinely measured was the frame rate. The direction of the hunch is reasonable, since everything moving on that page did depend on the same frame source.
Measure rafPerSecond before accusing the animation code
The eye cannot tell "the animation is broken" apart from "the frames are sparse". Both produce a similar picture. So the first step I now insist on is not reading animation code, it is counting how many times requestAnimationFrame actually gets called in one second on the page under inspection.
const rafPerSecond = await page.evaluate(() => new Promise((resolve) => {
let frames = 0;
const start = performance.now();
const tick = () => {
frames += 1;
if (performance.now() - start >= 1000) resolve(frames);
else requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}));If it reads around 1, the jank is throttling, not a bug in the animation code. That single reading closes off the entire branch of the investigation I had been about to walk into. There is no point tuning easing in an environment that hands you one frame per second to render it with.
What automation deserves: state, ordering, and final values
The rule I took away from this fits in one sentence: never diagnose animation smoothness from an occluded automation window. What you verify through automation is the logic, that is state, ordering, and final values. None of the three care how many frames got rendered.
That landing had several things that are logic-shaped and pleasant to check this way. The entrance animations are built inside the IntersectionObserver callback at the moment of the trigger, run once, and then the observer is released. The anti-flash pre-state on the hero is applied as synchronous inline style before the CSS hold is lifted, with the characters at translateY 105 percent inside a clip and the other elements at opacity 0. And entrance animations must not replay when a visitor scrolls back and forth, while scroll-synced effects are allowed to keep running.
All of that can be stated as a comparison of values rather than as a judgement of feel.
// the pre-state has to be in place before the element enters the viewport
const before = await page.evaluate(() => {
const el = document.querySelector('[data-reveal]');
return { opacity: el.style.opacity, transform: el.style.transform };
});
await page.evaluate(() => {
document.querySelector('[data-reveal]').scrollIntoView();
});
// final values, once the trigger has fired
const after = await page.evaluate(() => {
const cs = getComputedStyle(document.querySelector('[data-reveal]'));
return { opacity: cs.opacity, transform: cs.transform };
});One bug on that page is exactly the kind this catches and watching would never catch. A reveal safety net that unhides every element after N seconds produced a strange ordering: the text appears, then the animation runs, then the text appears again, and it showed up when I scrolled at a relaxed pace. The fix narrows what that net is allowed to do. It may only rescue elements that are on screen but whose observer failed, at a threshold of two strikes on a 1.5 second interval, not content still below the fold. That is purely a matter of ordering and state, and one frame per second is more than enough to prove it.
Real jank still exists, and it does not live here
The conclusion above is easy to stretch too far, so it is worth separating out. This does not mean every stutter on that page was fake.
The same landing had jitter that was genuinely real, caused by stacking a ken burns or CSS scale on top of a video that already carries its own camera motion. The per-frame resample reads as stuttering. The fix there is not about tooling but about layers: the video needs its own compositor layer, and the intro characters get promoted to a layer too, then released once the animation completes.
.hero-video {
transform: translateZ(0);
will-change: transform;
}The difference becomes obvious once the measurement step sits at the front. That kind of jitter is still there when the frame rate is normal, while the choppiness I was chasing at the start disappears the moment a human is looking at the window.
What changed in how I verify
- An automation browser runs the page under conditions no visitor ever experiences, one of them being a window that is not visible. Smoothness is not something worth judging from there.
- Before accusing the animation code, count the frames that are actually rendered. A reading of around one per second answers the question without opening a single file.
- State, ordering, and final values hold steady at any frame rate, which is precisely why they are the part worth handing to automation.
- If a symptom disappears purely because a human is looking at the window, the symptom belongs to the tool, not to the page.