Images compressed. CLS at zero. Fonts optimized. Yet Mobile Performance was stuck at 74. LCP sat at 4.7 seconds — still red.
The culprit turned out to be a single line in motion/react (Framer Motion):initial={{ opacity: 0 }}. Once you understand what this does in SSR, you'll never wrap an LCP image with an opacity animation again.
1What Is LCP?
LCP (Largest Contentful Paint) measures when the largest visible element renders on screen — typically a hero image or large text block. Google considers 2.5 seconds or less as "Good."
LCP Weight in PageSpeed
LCP accounts for 25% of the PageSpeed score. Dropping LCP from 4.7s to 3.4s directly impacts 10+ points. Since LCP represents "the moment a user sees meaningful content," it also correlates with perceived speed.
The problem lies in how Lighthouse identifies LCP elements. Elements with opacity:0 are excluded from LCP candidates because they're invisible. Only when JavaScript changes opacity to 1 does LCP get re-measured. This is exactly the motion/react trap.
2The Culprit — opacity:0
Nearly every motion/react tutorial shows this pattern:
// ❌ This code destroys LCP
<motion.div
initial={{ opacity: 0, y: 60 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
>
<img src="/hero.avif" fetchPriority="high" />
</motion.div>Starting at opacity: 0 creates a smooth fade-in effect. The problem: in SSR frameworks like Next.js, this initial value gets baked into the server-rendered HTML as an inline style.
The Core Problem
The SSR HTML contains style="opacity:0;transform:translateY(60px)" inline. The browser sees this image as "not visible" and excludes it from LCP candidates. Only after the JS bundle loads and motion/react initializes does opacity flip to 1, finally triggering LCP measurement.
Inspecting the server-rendered HTML confirms this:
<!-- Server-sent HTML (verified via curl) -->
<div style="opacity:0;transform:translateY(60px)">
<img src="/hero.avif" fetchpriority="high"
width="900" height="1118" />
</div>
<!-- After JS execution (seconds later) -->
<div style="opacity:1;transform:translateY(0px)">
<img src="/hero.avif" fetchpriority="high"
width="900" height="1118" />
</div>The image loads fast, but the wrapper's opacity:0 tells Lighthouse "not visible yet." LCP only fires when JS sets opacity to 1. On mobile, JS execution takes 3–5 seconds — so even a ready image produces late LCP.
3What Happens in SSR
In a standard React SPA, this matters less — everything is client-rendered anyway. But Next.js generates initial HTML server-side. motion/react applies the initial values during SSR so the animation start state appears before hydration. This is intentional behavior — but lethal for LCP.
| Time | State | Lighthouse Verdict |
|---|---|---|
| 0ms | HTML arrives, opacity:0 inline | Image invisible → excluded from LCP |
| ~500ms | Image download complete | Still opacity:0 → ignored |
| ~1,500ms | FCP (text renders) | Another element becomes interim LCP |
| ~3,000ms | JS bundle loads + hydration | motion/react initialization begins |
| ~4,700ms | opacity: 0 → 1 transition | LCP finally measured → 4.7s |
The image is ready at 500ms, but the opacity flip at 4,700ms is what counts as LCP. No amount of image optimization can fix this.
4The Fix — Switch to Scale
The fix is straightforward: remove opacity animation from the LCP image wrapper. Use scale transform for a visually similar effect instead.
// ❌ Before — opacity:0 delays LCP
<motion.div
initial={{ opacity: 0, y: 60 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
>
<img src="/hero.avif" fetchPriority="high" />
</motion.div>
// ✅ After — scale only, opacity always 1
<motion.div
initial={{ scale: 0.97 }}
animate={{ scale: 1 }}
transition={{ duration: 1.2, delay: 0.4 }}
>
<img src="/hero.avif" fetchPriority="high" />
</motion.div>Why Scale Works
transform: scale(0.97) makes the element 3% smaller — but opacity remains 1, so Lighthouse sees the image as "visible." LCP fires as soon as the image loads.
The difference between scale 0.97 and 1 is virtually invisible. It gives a subtle "growing in" feel without affecting user experience.
Text, buttons, and below-fold content can keep opacity animations. Only the LCP image wrapper needs the change.
// ✅ Text, buttons — opacity animation is fine
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
>
<h2>Our Services</h2>
<p>Description text...</p>
</motion.div>
// ✅ Below-fold images — not LCP anyway
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
>
<img src="/below-fold.webp" loading="lazy" />
</motion.div>The rule is simple: "Never use initial opacity:0 on elements that could be LCP candidates."
5Second Trap — Missing Lazy Loading
After fixing opacity, LCP still didn't drop as much as expected. Digging deeper revealed that below-fold images were missing loading="lazy".
Without the loading attribute, browsers download all images immediately. Six images competing for bandwidth means the hero image downloads slower.
Bandwidth Competition
HTTP/2 multiplexes resources over a single connection, but bandwidth is finite. When the hero image (124KB) downloads simultaneously with 5 below-fold images (580KB total), the hero gets a smaller share. On mobile 3G/4G connections, this difference is massive.
// ❌ Before — all images load immediately
<img src="/service-ai.webp" />
<img src="/service-network.webp" />
<img src="/service-software.webp" />
<img src="/bg-texture.webp" />
<img src="/bg-meeting.webp" />
// ✅ After — below-fold uses lazy, hero loads first
<img src="/hero.avif" fetchPriority="high" /> // Hero
<img src="/service-ai.webp" loading="lazy" />
<img src="/service-network.webp" loading="lazy" />
<img src="/service-software.webp" loading="lazy" />
<img src="/bg-texture.webp" loading="lazy" />
<img src="/bg-meeting.webp" loading="lazy" />| Strategy | Target | Attribute |
|---|---|---|
| Eager load | Hero image (above fold) | fetchPriority="high" |
| Lazy load | Below-fold content images | loading="lazy" |
| Warning | Never lazy-load the hero image | LCP delays until viewport entry |
6Preload + AVIF Finishing Touches
After fixing opacity and lazy loading, two final optimizations:
Hero Image: WebP → AVIF
Converting the hero image from WebP to AVIF delivered another size reduction. At equivalent quality, AVIF is 40–60% smaller than WebP.
| Format | Size | LCP Impact |
|---|---|---|
| Original PNG | 5.7 MB | Download alone takes seconds |
| WebP q80 | 124 KB | Fast enough |
| AVIF q40 | ~60 KB | Additional savings |
Preload Only the Hero
<link rel="preload"> tells the browser to start downloading before HTML parsing. But overusing preload backfires.
<!-- ✅ Preload hero image only -->
<link
rel="preload"
href="/images/hero.avif"
as="image"
type="image/avif"
fetchpriority="high"
/>
<!-- ❌ Don't do this — preloading everything -->
<link rel="preload" href="/images/service-1.webp" as="image" />
<link rel="preload" href="/images/service-2.webp" as="image" />
<link rel="preload" href="/images/bg-texture.webp" as="image" />Preload Rules
- Use preload on the LCP image only.
- Preloading multiple resources dilutes priority and negates the benefit.
- Chrome warns if a preloaded resource isn't used within 3 seconds.
- Preloading below-fold images forces them to compete with the hero for bandwidth.
7Results
Three fixes — opacity removal, lazy loading, AVIF conversion — combined:
| Metric | Before | After | Change |
|---|---|---|---|
| Mobile Performance | 74 | 88 | +14 points |
| Desktop Performance | 96 | 97 | +1 point |
| LCP (Mobile) | 4.7s | 3.4s | -1.3s |
| LCP (Desktop) | 1.0s | 0.6s | -0.4s |
Mobile LCP dropped from 4.7s to 3.4s. Still above the 2.5s "Good" threshold, but combined with previous phases (image compression, CLS fix, font optimization), the journey went from 110.2s down to 3.4s.
Contribution by Fix
Eliminated JS execution wait
Resolved bandwidth competition
Additional download size reduction
LCP Optimization Checklist
The numbers in this article are real measurements from a specific site. Results vary by site structure and server environment. PageSpeed scores fluctuate ±3–5 points between runs.
PageSpeed Optimization Series
The complete journey from Mobile 38 to 88, step by step.