treeru.com

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.

4.7s
Before LCP
3.4s
After LCP
74→88
Mobile Score
0.6s
Desktop LCP

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.

TimeStateLighthouse Verdict
0msHTML arrives, opacity:0 inlineImage invisible → excluded from LCP
~500msImage download completeStill opacity:0 → ignored
~1,500msFCP (text renders)Another element becomes interim LCP
~3,000msJS bundle loads + hydrationmotion/react initialization begins
~4,700msopacity: 0 → 1 transitionLCP 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" />
StrategyTargetAttribute
Eager loadHero image (above fold)fetchPriority="high"
Lazy loadBelow-fold content imagesloading="lazy"
WarningNever lazy-load the hero imageLCP 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.

FormatSizeLCP Impact
Original PNG5.7 MBDownload alone takes seconds
WebP q80124 KBFast enough
AVIF q40~60 KBAdditional 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:

MetricBeforeAfterChange
Mobile Performance7488+14 points
Desktop Performance9697+1 point
LCP (Mobile)4.7s3.4s-1.3s
LCP (Desktop)1.0s0.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

opacity:0 → scale(0.97)(LCP 4.7s → 3.8s)

Eliminated JS execution wait

Added loading='lazy'(LCP 3.8s → 3.5s)

Resolved bandwidth competition

Hero AVIF + preload(LCP 3.5s → 3.4s)

Additional download size reduction

LCP Optimization Checklist

Never use initial={{ opacity: 0 }} on LCP image wrappers
Use scale(0.97→1) transform instead of opacity for entry animations
Inspect SSR HTML via curl to verify no opacity:0 inline styles
Add loading='lazy' to all below-fold images
Use fetchPriority='high' + preload only on the hero image
Convert hero image to AVIF for additional size savings
Opacity animations are fine on non-LCP elements (text, buttons, below fold)

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.