Next.js Sites Fail LCP Because of One framer-motion Prop. Mine Had a 4,190ms Render Delay. Fixed in 4 Lines.
Open PageSpeed Insights on your Next.js site right now. If every metric is green except LCP, and your hero headline fades in when the page loads, I can...
Garvit Sharma
12 June 2026 · 6 MIN READ
Next.js Sites Fail LCP Because of One framer-motion Prop. Mine Had a 4,190ms Render Delay. Fixed in 4 Lines.
webight.comOn this page
Open PageSpeed Insights on your Next.js site right now. If every metric is green except LCP, and your hero headline fades in when the page loads, I can probably name the exact line of code responsible without ever seeing your repo.
I know because that line was sitting in my own hero for months.
In June 2026 I ran a full audit on webight.com. Mobile scores: 90 performance, 96 accessibility, 96 best practices, 100 SEO. Decent. Most agencies would screenshot that and post it. But 90 bothered me, so I opened the LCP breakdown, and there it was: the hero headline, my LCP element, had a render delay of 4,190ms.
Read that again. The server answered fast. The HTML containing the headline reached the phone quickly. And then the browser sat on it for over 4 seconds before painting a single visible pixel of my most important sentence.
The fix took 4 lines of CSS. Render delay dropped to 382ms. The site now scores 96/100/100/100 on mobile, with this fix plus a few smaller ones (inlining CSS through experimental.inlineCss, which removed 22.5 KiB of render-blocking stylesheets, and deleting two leftover third-party scripts).
One big idea in this post, and it applies to every Next.js site using framer-motion on the hero: the browser cannot count what it cannot see. If your headline starts at opacity 0, your LCP waits for your entire JavaScript bundle. Everything below is me proving that and handing you the fix.
The prop that costs you four seconds
Here is what my hero looked like. You have probably written something nearly identical, because every framer-motion tutorial on the internet teaches this exact pattern:
"use client";
import { motion } from "framer-motion";
export function Hero() {
return (
<h1 className="text-5xl font-semibold tracking-tight">
<motion.span
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="block"
>
Websites that actually sell.
</motion.span>
</h1>
);
}
Looks harmless. Renders a nice fade-up on load. Now look at what Next.js actually ships to the browser. View source on the page and find your headline:
<span class="block" style="opacity:0; transform:translateY(24px)">
Websites that actually sell.
</span>
See, framer-motion serializes your initial values into the server-rendered HTML as inline styles. That part is correct behavior. Without it, the headline would flash fully visible and then snap to invisible before animating, which looks broken.
But follow the consequence. The browser receives the HTML, parses it, paints the page. Your headline is in that first paint. At opacity 0. Zero visible pixels. Chrome only records an LCP candidate when the element actually becomes visible, so the clock keeps running.
When does it become visible? When animate={{ opacity: 1 }} executes. And that requires the full chain: download the JS bundle, parse it, run React, hydrate the component tree, mount framer-motion, start the animation. On a throttled mid-range phone, the kind PageSpeed simulates and the kind most of my Indian traffic actually uses, that chain took 4,190ms on my site.
Your headline was sitting in the HTML the whole time. Text. The cheapest thing a browser can paint. Held hostage by a JavaScript animation library so it could fade in for 600 milliseconds.
That trade is bad and nobody making it has done the math.
The 4-line fix
Replace the motion component with a plain span:
export function Hero() {
return (
<h1 className="text-5xl font-semibold tracking-tight">
<span className="hero-line block">
Websites that actually sell.
</span>
</h1>
);
}
Then add this to your global CSS. These are the 4 lines from the title:
@keyframes hero-rise {
from { transform: translateY(24px); }
to { transform: translateY(0); }
}
.hero-line {
animation: hero-rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
}
No "use client" needed for this component anymore. No framer-motion import in the hero. If this was the only place you used it, that is also 30-plus KiB of library weight gone from your critical path.
My render delay went from 4,190ms to 382ms. Same visual effect to any human eye: the headline still rises into place with an ease-out curve. The motion survives. The delay dies.
Why transform paints on frame one and opacity-from-0 does not
This is the part worth actually understanding, because once you get it you will never write the slow version again.
An element with transform: translateY(24px) is fully visible. It is just sitting 24 pixels lower than its final position. The browser paints it on the very first frame, mid-animation, full opacity, and the LCP entry is recorded right there. The fact that it is still moving is irrelevant to the metric. LCP measures when pixels appear, and pixels appeared.
An element at opacity: 0 has no pixels. There is nothing to record. The metric waits.
The second half of the win: CSS animations need zero JavaScript. The browser starts them the moment it has the HTML and the stylesheet, which on a properly built Next.js page is the first render pass, hundreds of milliseconds in. Hydration can take its 4 seconds on a cheap phone and the animation does not care. It already ran. The JS-driven version chains your first impression to your slowest asset. The CSS version chains it to your fastest.
There is one trap here. If you write the CSS keyframe as from { opacity: 0 }, you rebuilt the same problem without framer-motion. Slightly less of it, since CSS starts earlier than hydration, but the element still paints invisible on frame one and LCP still waits for the fade. The rule has nothing to do with which library you use. Never animate your LCP element from opacity 0. With any tool. Move it, scale it, slide it. Keep it visible from the first frame.
If you genuinely want a fade on the page, fade the subheading, the buttons, the badge row. Fade everything except the one element PageSpeed has decided is your LCP. That element earns motion through transform only.
Check your own site in 90 seconds
For example, here is the exact sequence I would run on your site:
- Run your URL through PageSpeed Insights, mobile tab.
- Expand the LCP diagnostic. It splits LCP into TTFB, load delay, load time, and render delay. If render delay is the fat slice and your LCP element is text, an animation is the suspect.
- View source on your live page (source, the actual server response, before JavaScript touches anything). Find your headline. Look at its inline style attribute.
- If you see
opacity:0in there, that's it. You found it. You already have the 4 lines.
The whole check took me under 2 minutes on my own site and the fix shipped the same evening.
Where framer-motion is still fine
I did not rip framer-motion out of webight.com and I am not telling you to. Below the fold it is free. By the time a user scrolls to your features section, hydration finished long ago and scroll-triggered animations cost you nothing on the metric. Hover states, layout animations, exit transitions, page transitions: all fine. The library is good at its job.
The problem lives in exactly one place: the element Chrome picks as your largest contentful paint, during initial load. One element. One prop. On most marketing sites that element is the hero headline, which is also exactly where every tutorial tells you to put initial={{ opacity: 0 }}. So thousands of Next.js sites ship this identical wound and their developers stare at a 90 score wondering why, because the code looks clean and the animation looks smooth on their M-series laptop.
Their laptop hydrates in 300ms. Their visitor's phone takes 4 seconds. The metric is measured on the phone.
So here is the deal. View source on your site tonight. Find your headline. If opacity:0 is sitting in that inline style, you are 4 lines of CSS away from the easiest LCP win of your year, and you have no excuse left because the lines are right here, copy-paste ready. Run the before and after through PageSpeed and send me both numbers. I read every reply, and I want to see how much time that one prop was stealing from you.
We build custom platforms, websites, and automation.
The two people who build it are the two you talk to, and every price is on the page.

Garvit Sharma
Full-stack developer and co-founder of Webight, a two-person web and AI studio in India. He writes these from real client work. More about us.
Keep reading
// RELATEDA Client Paid 3 Agencies for SEO Before Me. None of Them Opened the Website Code.
webight.comA Client Paid 3 Agencies for SEO Before Me. None of Them Opened the Website Code.
7 MIN READ
One Cloudflare Worker Saved My Client's App When Supabase Got Blocked. It Also Cut TTFB in Half.
webight.comOne Cloudflare Worker Saved My Client's App When Supabase Got Blocked. It Also Cut TTFB in Half.
6 MIN READ
The Contact Form Setup That Stopped Landing in Spam. Resend, Next.js, DKIM.
webight.comThe Contact Form Setup That Stopped Landing in Spam. Resend, Next.js, DKIM.
6 MIN READ