Skip to content
All writing

Fixing a 6-second LCP caused by a hero fade-in animation

My portfolio's hero text stayed invisible until React hydrated. I moved its entrance to CSS and cut mobile LCP from 6s to 1.3s, after one false start.

By 4 min read

My portfolio's hero paragraph was the page's Largest Contentful Paint element, and it sat at opacity: 0 until React hydrated, then waited out a one-second animation delay. On a throttled phone that meant LCP of about 6 seconds for a static page. I moved the entrance into CSS, animated only transform, and got it to 1.3 seconds. My first attempt, a CSS fade, saved less than half a second, and it failed because of which paints Chrome counts toward LCP.

How slow was it on a phone?

On my fast laptop the page scored fine, so I measured it the way a mid-range phone loads it: Playwright driving Chromium with a 4x CPU slowdown and a slow 4G network, reading LCP from a PerformanceObserver, median of three runs.

lcp.cjs
const cdp = await context.newCDPSession(page);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
await cdp.send("Network.emulateNetworkConditions", {
  offline: false,
  latency: 150,
  downloadThroughput: (1.6 * 1024 * 1024) / 8,
  uploadThroughput: (750 * 1024) / 8,
});
await page.addInitScript(() => {
  window.__lcp = [];
  new PerformanceObserver((list) =>
    list.getEntries().forEach((e) => window.__lcp.push(e)),
  ).observe({ type: "largest-contentful-paint", buffered: true });
});

The live site came back at 5.8 to 6.4 seconds. First Contentful Paint came in at 1.7 to 2.0 seconds, so the page painted early, and its largest element stayed invisible for another four seconds.

Why was the hero invisible for six seconds?

The hero used motion for its entrance:

<motion.div
  initial={{ opacity: 0, y: 24 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{ duration: 1, delay: 1 }}
>

Motion renders initial into the server HTML as style="opacity:0;...". The animation can only start once the JavaScript has downloaded and React has hydrated, and then it waits another second. On a slow phone, hydration alone takes a few seconds. The paragraph arrived with the first bytes of HTML, and you still couldn't see it for six seconds.

The name above it had the same problem in a different form: each letter started at translateY(110%) inside an overflow: hidden wrapper, so the <h1> was blank until hydration too.

Why didn't a CSS fade fix it?

I tried the obvious fix first: the same fade in CSS, which runs from first paint without waiting for React:

@keyframes rise {
  from { opacity: 0; transform: translate3d(0, 1.1rem, 0); }
}

The median LCP on my local build dropped from 5.08 to 4.70 seconds, and Chrome now picked a small "Full-Stack Engineer" label as the LCP element instead of the paragraph.

I think the compositor explains it. Chrome runs a CSS opacity animation there, off the main thread. The paragraph's only main-thread paint happened at opacity: 0, the compositor then faded it in without another paint, and Chrome never counted it as an LCP candidate. LCP fell through to the next element that painted visibly: a small label still fading in through JavaScript.

What fixed it?

I kept the movement and dropped the fade. The paragraph is visible from its first paint and slides into place:

@keyframes settle {
  from { transform: translate3d(0, 1.5rem, 0); }
}
<div className="motion-safe:animate-settle motion-safe:[animation-delay:200ms]">

I gave the name letters the same treatment: a CSS letter-up keyframe with a staggered animation-delay per letter instead of a motion.span each. If you turn on reduced motion in your system settings, motion-safe: skips the animation.

The results

VersionLCP runs (ms)Median
Live site, before5832, 5972, 64165.97 s
Local build, before5052, 5076, 50805.08 s
CSS fade2576, 4696, 53364.70 s
CSS, transform only1640, 1752, 20561.75 s
Live site, after1160, 1260, 14441.26 s

After the fix, LCP equals FCP in every run: the largest element is visible the moment the page first paints.

Two more problems I found in the audit

  • A font axis that never rendered. I loaded the headline font's width axis for a hover effect that set wdth to 125. The axis only goes from 75 to 100, so the browser clamped 125 to the default and the hover changed nothing: the letter measured 58.72px either way. Dropping the axis took the font file from 128 KB to 75 KB.
  • GSAP running one loop. GSAP and ScrollTrigger were there only to drive Lenis's animation frame loop, which Lenis can run itself. I removed them and an unused three, and the home page's JavaScript fell from 811 KB to 699 KB.

Takeaways

  • Never start your LCP element at opacity: 0 behind JavaScript. Server rendering doesn't help if the server renders it invisible.
  • Animate transform on the LCP element. A CSS opacity fade can still hide it from LCP.
  • Measure on a throttled profile. My laptop scored this page fine while a throttled phone waited six seconds.

ShareXLinkedIn