Skip to main content

Don't hide the LCP image behind animations

Remove CSS transitions and animations from LCP elements. If your theme or a third-party app applies fade-in effects, page transitions, or reveal animations, then these can delay Largest Contentful Paint (LCP) by several seconds, even after the image has fully downloaded.


Chrome excludes elements with opacity: 0 from LCP candidacy because they provide no visual value. When CSS applies an initial state like opacity: 0 with a transition to opacity: 1, the browser waits until the element repaints at a non-zero opacity before recording the LCP event. The image might finish downloading in two seconds, but if a JavaScript-driven animation doesn't trigger the fade-in until six seconds into page load, LCP is recorded at six seconds.

This problem shows up in several common patterns:

  • Page transition animations in theme settings that hide all content until a transition effect plays. This is the single most impactful animation-related LCP issue and the easiest to fix.
  • Fade-in reveal classes, such as .is-loading or .animated-reveal, that set opacity: 0 on images and wait for JavaScript to remove the class after DOMContentLoaded.
  • Hero section animations that animate opacity on headings, logos, or images when they enter the viewport. These alone can waste 650 to 900 ms of LCP time.
  • Animate On Scroll libraries hard-coded into hero sections or applied site-wide, which hide content until scroll-triggered JavaScript runs.
  • display: none toggled by JavaScript to hide content until a reveal fires. This is worse than opacity-based approaches because display: none removes the element from layout entirely. When JavaScript later sets it to display: block, the browser has to recalculate layout for the element and everything around it, causing both a delayed LCP and a Cumulative Layout Shift (CLS) spike as surrounding content reflows.

In real case studies, removing fade-in transitions from LCP images has produced improvements of three to six seconds in LCP. One store saw mobile LCP improve from 3.6 s to 2.2 s after removing the hero fade-in, with a 9.4 percent increase in add-to-cart rate.


Anchor to Disable page transition animations firstDisable page transition animations first

The most impactful fix is often a single toggle. Check Online Store > Themes > Customize > Theme settings > Animations for options like Animate between pages or Page transitions. Not all themes have this setting, but if it exists, disable it. This one change has fixed Core Web Vitals issues for multiple stores with no code changes required.

Anchor to Remove fade-in animations from LCP elementsRemove fade-in animations from LCP elements

Don't apply opacity transitions, fade-in classes, or reveal animations to the LCP element, which is typically the hero image, main product image, or hero heading. If animations are used for below-the-fold content, then make sure the LCP element is excluded.

Anchor to If you must keep a transitionIf you must keep a transition

If removing the animation isn't an option:

  1. Use a CSS @keyframes animation instead of a JavaScript-triggered transition. A keyframe animation starts on the element's first render, so opacity rises above 0 on the first animated frame and the element becomes an LCP candidate immediately. No script has to run first.
  2. Keep the duration short, ideally under 0.3 s.
  3. For page-wide transitions, start the animation from the pagereveal event, which fires before the new document's first render, and fall back to unanimated content in browsers that don't support it.

Anchor to Problematic pattern: fade-in class on all imagesProblematic pattern: fade-in class on all images

img {
opacity: 0;
transition: opacity 0.3s ease;
}

img.loaded {
opacity: 1;
}

JavaScript removes the class after DOMContentLoaded, but by then the LCP image has been invisible for seconds. The browser can't record LCP until the image repaints at a non-zero opacity.

Anchor to Problematic pattern: reveal animation through a classProblematic pattern: reveal animation through a class

{{ product
| image_url: width: 1000
| image_tag: class: 'animate-reveal', fetchpriority: 'high'
}}
.animate-reveal {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease, transform 0.6s ease;
}

.animate-reveal.visible {
opacity: 1;
transform: translateY(0);
}

The fetchpriority: 'high' option makes sure the image downloads quickly, but the CSS hides it until JavaScript adds the .visible class. The two optimizations cancel each other out.

Anchor to Problematic pattern: ,[object Object], as a reveal mechanismProblematic pattern: display: none as a reveal mechanism

.hero-image {
display: none;
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelector('.hero-image').style.display = 'block';
});

This is the worst approach. Unlike opacity: 0, which keeps the element in the layout tree and reserves its space, display: none removes the element from layout completely. The browser won't consider it for LCP and won't reserve any space for it. When JavaScript flips it to display: block, the browser has to lay out the element from scratch, shifting all surrounding content and triggering a CLS spike on top of the delayed LCP.

The download still happens. display: none doesn't stop an eager <img> from fetching its image, so you pay the bandwidth and get none of the LCP credit.

Info

This inverts for lazy images. Chrome, Safari, and Firefox don't load a display: none image that has loading="lazy", but they do load one hidden with opacity: 0. So display: none on a lazy image can stop a fetch you expected, while opacity: 0 never does.

{{ product
| image_url: width: 1000
| image_tag: fetchpriority: 'high'
}}

The image appears immediately when loaded, with no animation delay and no LCP penalty.

Anchor to Solution 2: Exempt the LCP image from animationsSolution 2: Exempt the LCP image from animations

If you want to keep animations for below-the-fold content, then exclude the first section:

{% if section.index > 1 %}
{%- comment -%} Sections 2 and later: safe to animate {%- endcomment -%}
{{ product
| image_url: width: 1000
| image_tag: class: 'animate-reveal'
}}
{% else %}
{%- comment -%} First section, and any context where section.index is nil {%- endcomment -%}
{{ product
| image_url: width: 1000
| image_tag: fetchpriority: 'high'
}}
{% endif %}

Test the animated case positively with section.index > 1, not the exempt case with section.index == 1. section.index is nil in static sections, in the online store editor, and in Section Rendering API responses, so if section.index == 1 sends those contexts down the animated branch and reintroduces the LCP delay this page is about. See Use section.index for position-aware optimizations.

Anchor to Solution 3: Use a CSS ,[object Object], animationSolution 3: Use a CSS @keyframes animation

A CSS animation runs on the element's first render, so it doesn't wait for a script:

@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

.hero-image {
animation: fade-in 0.3s ease forwards;
}

@media (prefers-reduced-motion: reduce) {
.hero-image {
animation: none;
}
}

Because the animation starts at the element's first render, opacity leaves 0 on the first animated frame and the element qualifies as an LCP candidate right away. This is the approach to reach for when a transition has to stay.

Anchor to Solution 4: Animate page-wide transitions from ,[object Object]Solution 4: Animate page-wide transitions from pagereveal

If the animation covers the whole page rather than a single element, then start it from the pagereveal event. pagereveal fires before the new document's first render, which is far earlier than DOMContentLoaded and doesn't wait for the theme's JavaScript bundle:

<script>
// Only hide content when the browser can reveal it again at the first render.
if ('onpagereveal' in window) {
document.documentElement.classList.add('page-transition');
window.addEventListener('pagereveal', () => {
document.documentElement.classList.add('page-transition--revealed');
});
}
</script>

Scope the hiding CSS to the .page-transition class, which is added only when the browser supports pagereveal:

.page-transition body {
opacity: 0;
}

.page-transition--revealed body {
opacity: 1;
transition: opacity 0.3s ease;
}

@media (prefers-reduced-motion: reduce) {
.page-transition body {
opacity: 1;
}
}

Browsers without pagereveal support never get the class, so they never hide the content. Without that fallback, an unsupported browser hides the LCP element permanently.

Anchor to Solution 5: Use ,[object Object], to trigger the transition after the image loadsSolution 5: Use onload to trigger the transition after the image loads

If you need a per-image transition in JavaScript, then use the image's native onload event instead of waiting for framework initialization or DOMContentLoaded:

<img
src="hero.jpg"
alt="Model wearing the spring jacket"
class="hero-image"
onload="this.classList.add('loaded')"
fetchpriority="high"
/>
<noscript>
<img
src="hero.jpg"
alt="Model wearing the spring jacket"
class="hero-image loaded"
/>
</noscript>
.hero-image {
opacity: 0;
transition: opacity 0.3s ease;
}

.hero-image.loaded {
opacity: 1;
}

The <noscript> fallback keeps the image visible when JavaScript is disabled. This costs more than the @keyframes approach, because LCP can't be recorded until onload fires and the opacity transition starts.

Anchor to Identify animation delays in Chrome DevToolsIdentify animation delays in Chrome DevTools

  1. Open the Network panel and note when the LCP image finishes downloading.
  2. Open the Performance panel, record a page load, and find the LCP marker in the Timings track. Compare it against the image download completion time.
  3. A large gap of more than 500 ms between download and LCP indicates that something is delaying paint.
  4. Inspect the image or its container in the Elements panel. Look for opacity, transition, animation, or transform properties. Check for classes like .is-loading, .animate-reveal, .fade-in, or similar.
  5. Check theme settings for any global animation toggles, such as page transitions, fade effects, or scroll animations.

  • Compare the image download completion time with the LCP event time in Chrome DevTools. A gap indicates a rendering delay.
  • Use the Performance panel to see when transitions execute relative to LCP.
  • Measure LCP with and without animations to quantify the impact. A conservatively estimated improvement of one to three seconds is common.
  • Check prefers-reduced-motion behavior. Themes that respect this media query might show better LCP for users with reduced motion enabled, which is a signal that animations are the bottleneck.


Was this page helpful?