---
title: Don't hide the LCP image behind animations
description: >-
  Remove CSS transitions and animations from LCP elements. Fade-in effects, page
  transitions, and reveal animations can delay LCP by seconds, even after the
  image has fully downloaded.
source_url:
  html: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/dont-hide-lcp-image-behind-animations
  md: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/dont-hide-lcp-image-behind-animations.md
api_name: liquid
---

# 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)](https://web.dev/lcp/) by several seconds, even after the image has fully downloaded.

***

## Why

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)](https://web.dev/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.

***

## How

### Disable 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.

### Remove 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.

### If 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`](https://developer.mozilla.org/en-US/docs/Web/API/Window/pagereveal_event) event**, which fires before the new document's first render, and fall back to unanimated content in browsers that don't support it.

***

## Examples

### Problematic pattern: fade-in class on all images

```css
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.

### Problematic pattern: reveal animation through a class

```liquid
{{ product
  | image_url: width: 1000
  | image_tag: class: 'animate-reveal', fetchpriority: 'high'
}}
```

```css
.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.

### Problematic pattern: `display: none` as a reveal mechanism

```css
.hero-image {
  display: none;
}
```

```javascript
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.

### Solution 1: Remove the animation entirely (recommended)

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

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

### Solution 2: Exempt the LCP image from animations

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

```liquid
{% 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](https://shopify.dev/docs/storefronts/themes/best-practices/performance/use-section-index).

### Solution 3: Use a CSS `@keyframes` animation

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

```css
@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.

### 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`](https://developer.mozilla.org/en-US/docs/Web/API/Window/pagereveal_event) 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:

```html
<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`:

```css
.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.

### Solution 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`:

```html
<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>
```

```css
.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.

### Identify animation delays in Chrome Dev​Tools

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.

***

## Testing

* Compare the image download completion time with the LCP event time in [Chrome DevTools](https://developer.chrome.com/docs/devtools). A gap indicates a rendering delay.
* Use the [Performance panel](https://developer.chrome.com/docs/devtools/performance) 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.

***

## References

* [Improve LCP by removing image transitions](https://performance.shopify.com/blogs/blog/improve-largest-contentful-paint-lcp-by-removing-image-transitions)
* [How CSS opacity animations can delay LCP](https://www.debugbear.com/blog/opacity-animation-poor-lcp)
* [Largest Contentful Paint (LCP)](https://web.dev/articles/lcp)
* [`image_tag`](https://shopify.dev/docs/api/liquid/filters/image_tag) filter
* [`image_url`](https://shopify.dev/docs/api/liquid/filters/image_url) filter
* [`section.index`](https://shopify.dev/docs/api/liquid/objects/section#section-index)
* [Mark the LCP image with `fetchpriority="high"`](https://shopify.dev/docs/storefronts/themes/best-practices/performance/set-fetchpriority-high-on-lcp-image)
* [Never lazy-load the LCP image](https://shopify.dev/docs/storefronts/themes/best-practices/performance/never-lazy-load-lcp-image)
* [Prevent image layout shift](https://shopify.dev/docs/storefronts/themes/best-practices/performance/prevent-image-layout-shift)

***
