search

How Hero Animations Were Killing My LCP (And How I Fixed It)

I recently redesigned my homepage hero and added a bunch of entrance animations – blur effects, staggered transitions, a particle canvas. It looked great. Then I checked Lighthouse and my LCP had jumped to 5.3 seconds.

After some digging, I brought it down to 2.4s with three small changes. No new tools, no server work. Just CSS tweaks and a few lines of JavaScript. Here’s what went wrong and how I fixed it.

The setup – a full-viewport hero with an entrance animation

The hero section on my homepage uses height: 100dvh (I wrote about dynamic viewport height units if you’re unfamiliar with dvh). It takes up the entire screen.

The only meaningful content above the fold is the h1 headline, which makes it the sole LCP candidate.

I recently added a choreographed entrance animation to the hero. Every element starts hidden and transitions in when JavaScript adds a .hero-loaded class. The h1 starts blurred and transparent, then rises into view.

Particle dots float in the background on a <canvas>. It looked polished – but it pushed my LCP from under 2 seconds to 5.3s.

What went wrong – three root causes

I ran Lighthouse, dug into the performance trace, and found three separate issues stacking on top of each other.

The animation hid the LCP element

This was the big one. My h1 started with opacity: 0 and filter: blur(12px). It only became visible after JavaScript fired on DOMContentLoaded and added the .hero-loaded class.

The problem: Chrome does not count an element with opacity: 0 as an LCP candidate. The browser considers it invisible, so it waits until the element is repainted at a visible opacity before recording the LCP timestamp.

That meant my LCP was delayed by the entire time it took for DOMContentLoaded to fire plus the transition duration.

Here’s what the CSS looked like before:

#home_hero h1 {
    opacity: 0;
    transform: translateY(30px);
    filter: blur(12px);
    transition-property: opacity, transform, filter;
    transition-duration: 1.2s, 1.2s, 1s;
    transition-delay: 0.15s;
}

And the JavaScript trigger was buried inside a DOMContentLoaded listener:

document.addEventListener("DOMContentLoaded", function() {
    document.getElementById("home_hero").classList.add("hero-loaded");
});

That combination was the single biggest contributor to my LCP jumping from under 2s to 5.3s.

Too many static will-change declarations

I had 12+ elements in my homepage CSS with permanent will-change properties – dashboards, floating badges, code snippets, logo tracks, reveal animations.

Every one of them forced the browser to promote that element to its own GPU compositing layer on initial load, before any animation even started.

will-change is intended to be used as a last resort, in order to try to deal with existing performance problems. It should not be used to anticipate performance problems. Excessive use of will-change will result in excessive memory use and will cause more complex rendering to occur as the browser attempts to prepare for the possible change. This will lead to worse performance. – MDN

I was doing exactly what MDN warns against. Twelve permanent compositor layers, all competing for GPU memory during the most performance-critical phase of page load.

A full-page canvas competing for the main thread

The hero section includes a subtle particle animation rendered on a <canvas> element. The script (hero-particles.js) ran immediately on load, created the canvas, calculated dot positions, and kicked off a requestAnimationFrame loop – all while the browser was still trying to render the LCP element.

On top of that, the CTA buttons (.home-buttons) started with filter: blur(4px), adding yet another expensive paint operation to the initial render.

None of these were catastrophic on their own. But stacked together during the critical rendering window, they were measurably slowing things down.

The fixes – small changes, measurable impact

None of these required rethinking the design. Each one was a small, targeted change.

Make the LCP element visible from the start

Two changes here. First, I changed the h1 starting opacity from 0 to 0.1. That tiny shift means the browser registers the element as an LCP candidate on the very first paint, before any animation runs.

Second, I moved the .hero-loaded trigger out of DOMContentLoaded and into a synchronous inline <script> placed right after the hero </section> tag, wrapped in requestAnimationFrame:

</section>
<script>requestAnimationFrame(function(){document.getElementById("home_hero").classList.add("hero-loaded")})</script>

Why requestAnimationFrame? It guarantees the browser paints one frame of the initial hidden state first, then applies the class.

Without it, the browser might batch the class addition into the same frame as the initial paint and you’d lose the animation entirely. With it, you get the visual effect and early LCP registration.

I also shortened the transition duration from 1.2s to 0.7s and removed the 0.15s delay. The updated CSS:

/* h1 - starts barely visible so browser counts it as LCP */
#home_hero h1 {
    opacity: 0.1;
    transform: translateY(30px);
    filter: blur(12px);
    transition-property: opacity, transform, filter;
    transition-duration: 0.7s, 0.7s, 0.55s;
    transition-delay: 0s;
}

Remove static will-change declarations

I deleted every permanent will-change from the homepage stylesheet. All 12 of them – dashboards, badges, code snippets, separator lines, logo carousels, reveal elements. Gone.

Modern browsers already promote actively-animating elements to the compositor when needed. Telling the browser to promote everything upfront was doing more harm than good.

After removing them, compositing cost dropped and I saw no visual difference in animation smoothness.

Defer the canvas animation

Instead of initializing the particle canvas immediately, I wrapped the entire setup in requestIdleCallback with a setTimeout fallback:

var defer = window.requestIdleCallback || function (cb) { setTimeout(cb, 1500); };
defer(function () { init(); }, { timeout: 2000 });

This lets the browser finish the critical rendering path before spending CPU cycles on a decorative animation. The particles appear about 1.5-2 seconds after load, which is fine because users are reading the headline during that time anyway.

I also removed the filter: blur(4px) from .home-buttons and limited its transition to opacity and transform only.

The results

MetricBefore (with animations)After (optimized)
LCP5.3s (red)2.4s (green)
FCP~1.2s1.2s (green)
TBT~120ms60ms (green)
CLS00 (green)

LCP dropped from 5.3s to 2.4s – nearly 3 seconds recovered. FCP stayed at 1.2s, TBT halved, and CLS remained at zero.

The 1.2s gap between FCP (1.2s) and LCP (2.4s) is mostly the h1 transition time. The browser needs opacity to progress past the initial 0.1 before it records the LCP timestamp. Starting at opacity: 0.1 with a 0.7s transition is the practical sweet spot between visual effect and performance.

LCP does not count invisible elements

This is the part I want you to remember: your LCP candidate must be visible on the first paint. If it starts at opacity: 0, the browser ignores it. Your LCP gets pushed to whenever that element finally becomes visible and gets repainted.

Starting at opacity: 0.1 is nearly invisible to the human eye but fully visible to the Core Web Vitals measurement.

Combine that with triggering your animation class via a synchronous inline script (wrapped in requestAnimationFrame) instead of waiting for DOMContentLoaded, and you can keep your entrance animations without sacrificing performance.

Removing static will-change and deferring the canvas work were smaller wins, but they cleared compositing overhead and freed the main thread during initial render. None of these changes needed a build step or a plugin. Just understanding what the browser actually measures and getting out of its way.

FAQs

Common questions about hero animation LCP optimization:

Why does opacity: 0 delay LCP?
Chrome ignores elements with opacity: 0 when calculating Largest Contentful Paint. The browser only records LCP once the element is repainted at a visible opacity. If your largest above-the-fold element starts fully transparent, LCP is delayed until the fade-in animation makes it visible.
What is the minimum opacity for an element to count as LCP?
Any value above 0 works. Setting opacity: 0.1 is a practical choice because it is nearly invisible to users but the browser considers the element visible and counts it as an LCP candidate on the initial paint.
Is will-change still useful in modern CSS?
Yes, but only as a targeted fix for known performance problems. Modern browsers automatically promote actively-animating elements to the compositor. Adding permanent will-change declarations in your stylesheet forces GPU layer promotion on load, consuming extra memory and increasing compositing cost. Toggle it via JavaScript when needed, then remove it after the animation ends.
Does requestIdleCallback work in all browsers?
Safari added support for requestIdleCallback in version 16.4 (March 2023), so it now works in all major browsers. For older Safari versions, use a setTimeout fallback: var defer = window.requestIdleCallback || function(cb){ setTimeout(cb, 1500); };
How does requestAnimationFrame in a sync script help LCP?
Placing a synchronous <script> right after the hero section with requestAnimationFrame ensures the browser paints one frame of the initial state (with opacity: 0.1) before adding the animation class. This lets the browser record the LCP on that first paint. Without requestAnimationFrame, the browser might batch the class change into the same frame and skip the initial state entirely, losing the animation.
Can entrance animations and good LCP coexist?
Yes. The key is making sure your LCP candidate is technically visible on the first paint. Start with a very low but non-zero opacity (like 0.1), trigger the animation class as early as possible (inline script, not DOMContentLoaded), and keep transition durations short. The visual difference between opacity: 0 and opacity: 0.1 at the start of a fast transition is imperceptible to users.
How do I identify my LCP element?
Open Chrome DevTools, go to the Performance tab, and record a page load. In the timeline, look for the LCP marker. Click it to see which element was identified as the Largest Contentful Paint. You can also use Lighthouse or PageSpeed Insights, which highlight the LCP element in the diagnostics section.

Summary

Three targeted changes dropped my homepage LCP from 5.3s to 2.4s: starting the h1 at opacity: 0.1 instead of 0, triggering the animation class via an inline script instead of DOMContentLoaded, and deferring non-critical GPU work.

If you’re running entrance animations on your LCP element, check whether it starts invisible. That single detail might be the reason your score is worse than it should be. For a deeper look at what LCP measures and all the ways to improve it, read my full guide on optimizing Largest Contentful Paint.

Join the Discussion
0 Comments  ]

Leave a Comment

To add code, use the buttons below. For instance, click the PHP button to insert PHP code within the shortcode. If you notice any typos, please let us know!

Savvy WordPress Development official logo