CSS reveal vs JavaScript
−16 ms
script per full-page scroll — real, and small
Stopping 8 perpetual animations
no effect
not one metric separated from noise
Articles shipping invisible
27 of 27
found by accident; now 0
There are two claims sitting in this repository's own CSS, written by me, with nothing behind them. One says that handing an animation to animation-timeline: scroll() means it "costs nothing measurable". The other says the JavaScript alternative "ruins INP". Both are the received wisdom. Neither had a number.
This site has a rule about that — three posts ago it cost me a weekend of taking unbacked measurement claims out of published work. The rule applies to code comments too.
So I measured it. It took four harness designs, and the first three each gave me a confident answer that was wrong in a different way.
What was actually running
Twenty-two chart components on this site gated a fade-and-rise on framer-motion's useInView: an IntersectionObserver, a React state commit, and an animation driver, per element, to move opacity and translateY — two properties the compositor can move on its own. A further set of animations carried repeat: Infinity and simply never stopped.
Counting only components belonging to live, indexed posts — which took two attempts, see below — that is 25 scroll reveals and 56 perpetual animations across 11 of the 22 live posts that have charts.
Both are on the research plan's anti-pattern kill-list. Neither had been measured.
The measurement
Two A/B builds of this repository, measured identically: same page, same server, same 4x CPU throttle, five reps each. The only thing that differs between arms is the mechanism. Absolute numbers are properties of the page — recharts, hydration, the article itself — so only the difference says anything, which is why analyse-reveal.mjs refuses to report on a single build.
Interactions are issued during the scroll, because the question is whether a mechanism holds the main thread when input arrives; an interaction on an idle page would never see the contention.
Result one: the reveal, which is real and small
What the Reveal Mechanism Costs Across a Full-Page Scroll
cloud-repatriation-sovereign-workloads-2026, median of 5 reps, 4x CPU throttle — bars are the full per-rep range
Script across a whole page scroll falls from 77.0 ms to 60.8 ms, and style recalculation from 224.5 ms to 199.7 ms. Neither pair of ranges overlaps, so both survive the separation rule.
That is a genuine saving and it is worth taking, because it is free. It is also 16 milliseconds, spread across the entire act of scrolling an article top to bottom, on a machine deliberately slowed to four times its real cost. Unthrottled, my M1 cannot resolve the difference at all.
Nothing else moved. Idle cost, worst interaction latency, and bytes transferred were all unseparated:
| framer-motion | CSS view() | separated? | |
|---|---|---|---|
| Script during scroll | 77.0 ms | 60.8 ms | yes, 1.3x |
| Style recalc during scroll | 224.5 ms | 199.7 ms | yes, 1.1x |
| Main thread busy, idle | 1.25% | 1.23% | no |
| Worst interaction | 96 ms | 88 ms | no |
| JavaScript transferred | 616.6 KB | 616.6 KB | no |
The bytes are the part worth dwelling on. Removing useInView from every chart component removes no dependency at all — framer-motion is still needed by the search modal and the card components, so the library ships either way. The intuition that "drop the animation library, drop the bundle" is simply not what happens when the library has more than one caller.
The received wisdom said this swap would fix INP. It moved INP by nothing at all, and moved script time by sixteen milliseconds.
Result two: the animations that never stop, which cost nothing
Eight Animations Repeating Forever, Against the Same Eight Stopped
global-tech-landscape-2026, median of 5 reps, 4x CPU throttle — every pair of ranges overlaps
I changed every repeat: Infinity on a live page to repeat: 0, rebuilt, and measured the same way.
Not one metric separated. Not idle cost, not the worst screen in a positional sweep, not scroll-phase script, not style recalc, not interaction latency, not long tasks. The medians wobble in both directions, which is what noise looks like.
This is the opposite of what the plan I was working from assumes, and the opposite of what I expected when I started. It has a mechanism: those animations move opacity, x, y and scale, and framer-motion hands those to the compositor, where the main thread never sees them.
The cost is real when the property cannot composite. On a different page — animating circle.cx, circle.cy and circle.r, which are SVG geometry and must be recalculated on the main thread every frame — I measured idle main-thread occupancy of 7.32% against 0.73%, a tenfold difference with no overlap between ranges. So the honest rule is not "decorative animation is expensive." It is:
That page, as it happens, is one nobody can read. Which is the third of my four mistakes.
The four wrong answers
Each of these produced a number. None of them announced itself.
One — I measured where nothing was running. The first harness idled at the top of the page and read main-thread busy time. It reported 0.01% for both arms and I nearly published "perpetual animation is free" on the strength of it. Every one of those animations is gated on animate={inView ? … : {}}. At scroll position zero, not a single one had started. The probe was pointed at a page where the thing under test did not yet exist.
The tell was that the two arms agreed exactly. Agreement between arms is what a broken probe produces, and it is indistinguishable from a real null unless you go looking for the mechanism you expected and fail to find it.
Two — I measured only one end of the page. Fixing that by idling after a full scroll found a large effect. But a perpetual animation costs the main thread only while its element is on screen — the browser does not spend style and layout on an SVG scrolled out of view. So idling at the bottom missed everything above it, and reported 0% for pages whose animation sits in the middle. The fix was a sweep: park at eight evenly spaced positions and measure each, because what a reader pays is the cost wherever they happen to stop.
Three — I measured a page nobody can read. The tenfold result came from radiative-cooling-space-2026, which is in this site's NOINDEXED_SLUGS: a demoted post, absent from the sitemap. The physics was fine. The claim "this site pays this" was not one that page could support. The same error had inflated my counts — 138 perpetual animations and 68 reveals across all components, against 56 and 25 across components belonging to live posts. I had been quoting the number that included the pages I had already decided readers should not see.
Four — I reported medians without ranges. Covered above; it is now enforced in the analyser rather than remembered.
What the measurement found that it was not looking for
Verifying that the CSS reveal degraded correctly, I checked what the server-rendered HTML looked like without JavaScript. The reveal I was replacing rendered style="opacity:0" into the static HTML and waited for React to hydrate before the content became visible.
Then I counted how many elements were like that across the site.
Content Shipped Invisible in the Static HTML of Live Posts
Counted from the built pages of all 27 live posts, before and after
All 27 live posts, 60 elements. And 27 of those 60 were one per post — because the wrapper around the entire article body in the post route was a motion div with initial={{ opacity: 0, y: 20 }}. Every article on this site was shipping its whole text inside an element styled invisible, waiting on JavaScript.
The text is in the DOM, so an extractor that ignores styling still reads it, and Googlebot renders JavaScript and would have seen the page normally. But anything that applies inline styles without executing scripts renders a blank article — and the plan this site is working from wants exactly those clients, the answer-engine crawlers, to be able to read and cite it. Trading that against a half-second fade-in is not a trade I would have made if anyone had put it to me in those words.
It is now zero of 27. The article wrapper is a plain div, and the CSS reveal animates from a visible default inside an @supports block, so a browser without scroll-driven animations — Firefox still ships them behind a flag — shows the content rather than a permanently transparent box.
An animation may enhance how content arrives. It may never decide whether it arrives.
That rule is now a comment in the stylesheet, which is where I should have put it before writing 60 elements that broke it.
What I changed, and what I did not
Shipped: the CSS reveal, applied by codemod to the 15 components where the transform is provably safe. It buys the 16 ms, and more importantly it is the version whose failure mode is visible content. One component is left for a human, because it has a paired motion div that is not a reveal and rewriting its closing tags would re-parent the wrong element — the codemod refuses rather than guesses.
Shipped: the article-body wrapper fix, which is the whole of the 27-post finding.
Not shipped: removal of the 56 perpetual animations. The plan's kill-list says to delete them. My own measurement says that on a live page, stopping them changes nothing I can detect. Deleting 56 animations across 11 posts is a visible change to how most of this site looks, and I am not making a visual change of that size on the strength of a null result. If they go, it should be because they are not earning their place editorially — which is a real argument, and a different one.
What this does not say
It does not say CSS animation is not faster than JavaScript animation. It says that on these two pages, for these two mechanisms, at 4x throttle, the difference is 16 ms of script and nothing else, and that a library with other callers does not leave the bundle when one caller stops using it.
It does not say perpetual animation is free. It says the cost tracks the property being animated and whether the element is on screen, and that a page animating compositable properties showed none of it while a page animating SVG geometry showed tenfold.
And it is a lab result. Synthetic input, one browser, one fanless M1 in Kathmandu, a local server with no network in the path. Chrome's field INP for real readers is a different number that I do not have.
The runs from the first two harness designs are not in that directory. They are the ones that measured the top of a page where nothing had started, and a post that readers cannot reach, and keeping them beside the corrected ones would put three incompatible measurement windows in one folder under the same field names. They are described in the README and nowhere else, which is the right amount of visibility for a measurement that was wrong.
