How it started
Loading speed is the one thing you must not economise on when building a website, and we tell clients so. Which is why we started with ourselves: we ran Lighthouse against our own site — not a client’s, our own. The mobile performance score came back at 76 with almost perfect everything else: time to first byte half a second, main-thread blocking 60 ms, layout shift 0.001. And LCP of 7.0 seconds.
That combination is odd. A bad LCP usually travels with a bad TBT: heavy JavaScript strangles both. Here the main thread was free, there are no images in the first screen, and fonts loaded with swap. The LCP breakdown explained everything in one line: time to first byte 0.55 s, the remaining 6.4 seconds were element render delay. The element had been delivered and not painted.
The first hypothesis — and why it failed
The markup of the first screen turned out to contain 95 elements with opacity: 0, including the LCP element itself — the paragraph beneath the heading. The cause was clear: framer-motion writes the initial animation state straight into the style attribute during server rendering. Until React loads and starts the animation, the text is physically invisible.
<p class="max-w-xl text-lg leading-relaxed text-ink-dim"
style="opacity:0;transform:translateY(20px)">
From landing pages to online banking — one partner for the whole cycle…
</p>The explanation looked complete, so we rewrote the first-screen animation in CSS: same keyframes, same curve, same duration — but starting on the first painted frame rather than after hydration. Built it, measured it.
Nothing changed. LCP was 5.0 s on the local build before and 5.0 s after. That is the heart of this walkthrough: a plausible explanation and a correct one are different things, and only a measurement tells them apart.
A hypothesis that explains the symptom but does not disappear along with it after the fix is a coincidence, not a cause.
The actual cause
Next we stopped looking at the styles of the LCP element and looked at what sits above it. The markup contained the intro overlay — the one with the counting numbers that sets the mood on desktop:
<div class="fixed inset-0 z-[1000] flex flex-col justify-between bg-base p-6">
… <!-- 000 · booting · Loading experience -->
</div>An opaque full-screen layer on top of everything. On a phone the intro should not be shown, and the code knew that — the condition was evaluated by a useCanRender3D() hook: desktop, fine pointer, enough cores and memory. The problem is when that condition becomes known. A hook is React, React is hydration. Until then the server does not know who is on the other end, and renders the overlay for everybody.
The result was a closed loop: removing the overlay requires JS; until JS arrives, the visitor stares at an empty dark screen even though all the content is already on their device. It also explained why the first fix did nothing: the transparency of the paragraph was irrelevant while something covered it from above.
The fix
The answer is to move the condition out of JavaScript and into CSS. A media query is evaluated during the very first style calculation, before any React, and on a phone the overlay simply never exists as a painted layer.
@media (hover: none), (pointer: coarse), (prefers-reduced-motion: reduce) {
.intro-gate {
display: none !important;
}
}The second half is that CSS rewrite of the first-screen animation. On its own it did nothing, but once the overlay was gone it became mandatory: remove the top layer and the very next thing you hit is opacity: 0. Two mistakes had been masking each other.
- 01
The overlay is hidden by a media query
The layer stopped being painted on devices where the intro is not shown — regardless of whether JavaScript has loaded.
- 02
The first-screen animation moved to CSS
Same keyframes, durations and curve. The only difference is when it starts: the first painted frame instead of the end of hydration.
- 03
Nothing was removed
On desktop the intro runs exactly as before. That matters: a signature animation is part of the product, not the thing you pay a metric with.
The result
Measured on one machine, on identical code, with real throttling (Lighthouse, mobile profile, --throttling-method=devtools) against a local production build — to remove the network and the variance from the picture.
How to check this on your own site
- Open the page source (
view-source, not the inspector — the inspector shows the state after JS) and find your heading text. If it is not there, LCP is a premature conversation: server rendering comes first. - In that same HTML, search for
opacity:0,visibility:hiddenandtransform: translatenear first-screen content. All of it is invisible text. - Look for elements with
position: fixedand a highz-indexthat always render and are hidden by a condition from JS. Splash screens, preloaders, consent banners. - Run Lighthouse with
--throttling-method=devtoolsand read the LCP breakdown. A large element render delay with a small TTFB is your case. - Check whether the visibility condition for anything large lives in a React hook. Anything a media query can decide should be decided by a media query.
The general rule we took away: anything that covers content must be able to disappear without JavaScript. If only a script can lift the layer, then the time it takes that script to load becomes the time it takes your content to appear — with everything that follows for user behaviour and for ranking.
Frequently asked questions
Why not simply remove the intro altogether?
Because on desktop it works: it is part of the impression the product makes, and we do not think paying with the product for a metric is right. The task was never “remove the animation” but “stop it interfering with the people it is not shown to”. After the fix, nothing changed on desktop.
Is this a Next.js problem or a framer-motion problem?
Neither. Both tools do exactly what they promise. The mistake is architectural: a condition that controls content visibility was only available after hydration. This repeats on any server-rendered stack — React, Vue, Svelte alike.
How much does this actually matter for the business?
LCP is one of the three Core Web Vitals, and those are ranking signals measured on real users, mostly mobile ones. But before search enters the picture: six seconds of dark screen means people who went back to the results page without seeing a single line. The money is lost before the rankings are.
Why publish a walkthrough of your own mistake?
Two reasons. First, this mistake is easy to repeat and we could not find it written up anywhere, so the piece is useful. Second, a walkthrough of your own miss with numbers can be verified in five minutes: open our site and run Lighthouse yourself. A case study you can check is worth more than one you have to take on faith.
Sources
Every claim here can be checked: below are the primary sources, not a retelling.
- 01web.dev — Largest Contentful Paint (LCP) — the definition of the metric and its breakdown, including element render delay
- 02Chrome for Developers — Lighthouse performance scoring — how the score is computed and how the throttling modes differ
These articles are written by engineers working on the projects — but they are not signed by name. The reason is the same one that keeps client logos off this site: nearly every project runs under an NDA or white-label, and a byline on a piece about a payment core points at the client as clearly as a logo would. Instead of names, we stand behind the text with rules — except for articles about our own open-source code, which carry the author’s name.
How we write and what we verify