The animation that never ran
Every build check I ran came back green, and the motion on the live page sat frozen.
By Andrew Pyle
It took two fixes and three deploy cycles to make one small hero move on the page people actually load.
01Nothing moving
The live page was perfectly still
The hero at the top of my site is a small deck I call the fleet. It has a row of agent glyphs that are supposed to pulse, and a six-step pipeline with a highlight that walks from one step to the next. On the live site, none of it was moving. The glyphs and the pipeline both sat still, and the deck looked like a screenshot of itself.
Every check I had run said it was fine. The type check passed, the build passed, and the prerender passed. Those were the checks I wrote down as verification, and each one is honest about what it tests. None of them watches an element change on the hydrated page in a browser. I had confirmed that the code compiled and rendered. I had not confirmed that it animated, and for an animation that difference is the whole feature.
The site is React, built with Vite, prerendered, and then hydrated in the browser. So there are two versions of the page: the static one the build writes, and the live one the browser takes over. The motion only had to work on the second one, and that was the one I had not looked at.
02The first suspect
A timer that kept restarting everything
The pipeline highlight ran on a timer. A useEffect started an interval every 680 milliseconds while the deck was playing, and each tick advanced an activeStep counter, modulo six, one for each pipeline step. This is the line that did it:
setInterval(() => setActiveStep((s) => (s + 1) % 6), 680)That state lived at the root of the deck, so every tick re-rendered the whole component, glyphs included.
On the live page, opacity was pinned to the first keyframe. Each re-render restarted every element from frame zero, so nothing got far enough into its cycle to look like motion. The glyphs were being reset more than once a second. From the outside that reads as frozen, and it was a good enough explanation that I took it to be the only one.
I fixed that half by deleting the timer. I removed the activeStep state, the effect, and its import. Each pipeline node got a CSS class and an animation delay based on its position, so one 4.2 second keyframe cycle, with the glow peaking at seven percent of the cycle, walks across the six nodes by itself. Nothing in React has to happen for the highlight to advance. I shipped it.
03Two samples, same answer
The loop was dead even without the timer
About twenty minutes later I shipped a second fix, because the first one had removed only one cause. With the re-renders gone, the glyphs should have pulsed. They did not. This time I read the same element on the live page twice, at two different moments, and compared the two readings. They were identical: opacity at the first keyframe and transform set to none.
The glyphs were framer-motion spans. Each had animate arrays for opacity, scale, background color and box shadow, and a transition that asked for the loop to run forever:
transition={{ duration: 1.7, delay: (i * 0.3) % 2.2, repeat: Infinity, ease: "easeInOut" }}I had a suspect ready, because framer cannot animate a CSS var(). But a comment above that component already warned about exactly that, and every animated value in it was concrete. So the values were not the cause.
What narrowed it was a control group I already had. The same file held other animations, trace pulses, the new pipeline glow, and an activity spark, all written as plain CSS keyframes. All of them were moving on the live page. They shared the page and the hydration with the glyphs. The only thing that was dead was the loop that framer was driving.
04What I still do not know
I replaced the loop and kept the question
I replaced each motion span with a plain span, a class, and an animation delay, driven by a 1.7 second CSS keyframe that takes opacity from .25 to 1 and back with a slight scale. The framer import left the deck. Pausing now comes from a class on the parent that sets animation-play-state to paused, and a reduced-motion media query turns the animation off. Then I ran the two-sample check on the live page again.
The part I never explained
I never found out why framer applied only the first keyframe on the hydrated build. I have an observation and a rule. I do not have a mechanism. One-shot entrance animations may well be fine there. It was the infinite keyframe loops that died. I would rather leave that gap visible than write in a cause that only sounds right.
The rule I wrote down
The rule I wrote for myself is narrow on purpose. Any continuous animation in a hero or a deck runs on CSS keyframes, a class, and a delay. A looping highlight is never driven by setInterval and setState at the root of a component. And I verify motion with two samples of the live, hydrated DOM, taken a moment apart. If the two samples agree, the animation is not running.
Related
writing
A deploy should drain, not kill
A routine deploy quietly ate a day of work, and the monitor that was supposed to notice reported nothing wrong.
writing
A status the page never learned
The tasks page went white the day a triage used a status value the client had never been told about.
writing
A cold cache is not free
My feature-flags page felt instant once I cached it, and the first request to each fresh worker after a deploy still paid about fifteen seconds.
writing
A 200 is not the same as access
My leak probe failed with seventeen leaks on its first real run. Every one of them was the same page that was never a leak.
writing
Deployed is not the same as served
The build passed, the deploy passed, and the page a visitor got was still the old one.