Your entrance animation is delaying your largest paint
An element that starts at opacity zero is never painted, so a fade in pushes Largest Contentful Paint out by the delay plus part of the animation.
A page looks calm and deliberate in the browser and slow in the report. The HTML is small, the images are the right size, the server answers in tens of milliseconds, and Largest Contentful Paint still comes back near two seconds. Nothing in the network waterfall accounts for the gap. The cause is the fade in that every block on the page begins with, and the browser is being completely reasonable about it.
What actually happens
Largest Contentful Paint is the moment the largest image or block of text inside the first viewport reaches the screen. The important word is reaches. The browser records a contentful paint when pixels of that element are drawn. An element at opacity: 0 draws nothing. It has a box, it has layout, its font may already be loaded and its image already decoded, and as far as the metric is concerned it has not appeared yet.
That turns a normal entrance into a timeline like this:
- The HTML and CSS arrive, the hero heading is laid out, and it is transparent.
- The animation waits out its delay, say 200 milliseconds.
- The animation runs from transparent to opaque over 600 milliseconds.
- The first frame in which the heading is actually drawn is the frame the browser reports as the paint.
The paint time is now the real paint time plus the delay, plus whatever part of the fade has to pass before something is on screen. Current engines take the first frame with a non zero opacity, so the delay is the part you pay for certainly. An entrance driven from JavaScript after a timer, or one that only starts when a class is added on load, can cost you the full duration as well.
Stagger makes it worse in the most well meaning way. The pattern where each child gets a delay 100 milliseconds larger than the last is lovely on a design review and expensive if the largest element is the fourth child, because the largest element is the one the metric watches.
The worst case is the page wide fade:
/* every metric that depends on paint now starts after 200ms */
body {
opacity: 0;
animation: enter 700ms 200ms ease forwards;
}Now nothing at all is painted during the delay. First Contentful Paint moves with it, Largest Contentful Paint moves with it, and no amount of work on the server or the images can pull the numbers back under the animation.
Speed Index is a separate injury. It scores how quickly the visible area fills in, by comparing each captured frame with the final frame. An element that is still moving at the second second keeps every frame different from the final one, so the score keeps accumulating after the content is technically there. A parallax layer, an endless marquee or a background that drifts forever will hold that number up even when Largest Contentful Paint is perfect.
Entrances built from transform behave differently, and the difference is the whole point. An opaque element translated by ten pixels is drawn in full on the first frame, inside the viewport, with all of its pixels. It is painted, it is counted, and the paint time is the honest one. The movement still costs a little on Speed Index and nothing on Largest Contentful Paint.
How to see it
Ask the browser which element it picked and when. Paste this into the console on the page itself, then reload:
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
console.log(Math.round(e.startTime), e.element?.tagName, e.element?.className);
}
}).observe({ type: 'largest-contentful-paint', buffered: true });Each line is a candidate. The last line is the one that counts. If the element printed there is the hero heading and the time is suspiciously close to your animation delay plus a frame or two, you have found it.
The measurement that settles the argument is the same page with the animations switched off. Build the switch into the page rather than editing CSS by hand, so you can run it as often as you like:
if (new URLSearchParams(location.search).has('still')) {
document.documentElement.classList.add('no-entrance');
}.no-entrance *,
.no-entrance *::before,
.no-entrance *::after {
animation: none !important;
transition: none !important;
opacity: 1 !important;
}Run both versions on a throttled profile, three runs each, and take the median rather than the best. In one project the same page reported 2.4 seconds with the entrance and 1.2 seconds without it, on identical bytes. The page had not become faster in any way a person would notice. It had stopped hiding the thing it had already drawn.
The fix
The rule that covers most of it: nothing in the first viewport starts at opacity zero.
- The hero heading, the hero image and the largest card are visible from the first frame. They are allowed to move, not to appear.
- Motion above the fold comes from
transformonly, over a short distance, with no delay or a delay under 100 milliseconds. - Stagger below the fold as much as you like. Elements outside the first viewport are not candidates, so their delays cost nothing on this metric.
- If a fade is unavoidable on a large element, start it from a visible value rather than from zero.
.hero h1 {
animation: rise 320ms cubic-bezier(0.2, 0, 0, 1) both;
}
@keyframes rise {
from { transform: translate3d(0, 10px, 0); }
to { transform: none; }
}
/* if the design insists on a fade, do not start at zero */
@keyframes soften {
from { opacity: 0.35; }
to { opacity: 1; }
}An element that begins at 0.35 is painted on the first frame, so the paint is recorded immediately and the fade is decoration on top of a visible page. It reads almost the same to a person and it costs nothing in the report.
The other half of the fix is what happens when the script does not run. Entrances are often triggered by an observer that adds a class when an element scrolls into view, which means the element is transparent until the class arrives. Write the CSS so the visible state is the default and the animation is the enhancement, not the other way round. A page whose content depends on a script to become visible is one failed request away from being blank.
Finally, respect the system setting:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
scroll-behavior: auto !important;
}
}One millisecond rather than none is deliberate. Code that waits for an animationend event still hears it, so nothing stalls for the people who asked for less movement.
How to check it worked
Run the observer snippet again in both modes. The element reported should be the same and the time should be close to identical:
# before, with the entrance
1840 H1 hero title
# after, transform only
1160 H1 hero titleThen look at a recorded filmstrip of the load rather than the numbers. The frames should show the heading present and settling into place, not an empty box followed by a sudden arrival. If the first three frames of your filmstrip are blank and the fourth is complete, an animation is still hiding something that was ready. The same principle applies to images and fonts: a subsetted font with matched fallback metrics gets text on screen early, and there is no point in doing that work and then covering it with a fade.
What to watch out for
- Opacity on an ancestor hides everything inside it. A card wrapper animating from zero hides its own image, and the image is the candidate the metric was going to use.
- An element that slides in from outside the viewport is not a candidate until it is inside the viewport. A long slide can feel elegant and still be recorded at the moment it arrives, which is the end of the animation.
- Lab numbers and field numbers move together here, because the delay is a constant that every device pays. A slow phone adds its own problems on top, it does not cancel yours.
- Do not solve this by removing animation from the design. The point is to stop using opacity as the mechanism for arrival, not to make the page static. Measure the change rather than arguing about it, the same way you would measure before optimising anything else.
Entrance animations are one of the few places where design intent and measurement disagree for a reason that is easy to explain, and easy to fix once it is explained. The browser will not give you credit for pixels it was told to keep invisible, so the cost of a fade is paid in full by the score even though the work was finished before the animation started. Start visible, move a little, and keep the motion for things the metric is not watching. That is also the difference between choosing a format because it looks right and choosing it because you compared the files, which is how the dot based images on this site ended up as PNG.
Questions and answers
- Does a CSS fade in really affect Largest Contentful Paint?
- Yes. The metric is about painted pixels, and an element at opacity zero has no painted pixels. The browser waits until the element is drawn with a visible opacity and reports that moment as the paint time, so every millisecond of animation delay is added to the score. The layout, the font and the decoded image can all be ready long before the number says they are.
- Which entrance animations are safe for performance?
- Anything that starts from a fully opaque state. A small translate, a scale from 0.98, a colour change or a blur on an element that is already visible are all painted on the first frame, so the paint time is the real paint time. Keep the travel inside the viewport, because an element sliding in from outside it is not a candidate until it arrives.
- How do I measure the cost of my own animations?
- Add a switch that disables every animation and transition, then measure the same page with and without it on a throttled profile. Use a PerformanceObserver on largest-contentful-paint so you see which element is reported and when. Three runs each and the median is enough to tell a 100 millisecond difference from a 900 millisecond one.
- Should I just remove animations for users who ask for reduced motion?
- Honour prefers-reduced-motion, but shorten the durations instead of removing the animation completely. If you set animation to none, any code waiting for an animationend event never hears it and the interface can stall. A duration of one millisecond is instant to a person and still fires the event.