Preventing Layout Shift from Scroll Reveals

A layout change within half a second of a user interaction is excluded from Cumulative Layout Shift, on the reasonable grounds that the reader asked for it. Scrolling is not an interaction for this purpose β€” which means a reveal that changes layout as the reader scrolls is recorded every time, on every element, on every visit. This guide covers which reveal implementations are layout-neutral, and the media reservation that usually matters more than any of them. It sits under Core Web Vitals for scroll and view transitions.

When to use this approach

  • A page of scroll reveals with a poor CLS score and no obvious culprit β€” the reveals themselves are the usual answer.
  • Before building a section of reveals. Choosing a layout-neutral implementation costs nothing at design time and is awkward to retrofit.
  • On any page with media inside animated sections. Unreserved images are frequently a larger contributor than the animation.
What makes a shift "unexpected" A layout change within 500 milliseconds of a user interaction is excluded from the metric, because the reader caused it. A scroll does not count as an interaction for this purpose, so a reveal that changes layout as the reader scrolls is always recorded β€” which is why scroll-driven reveals must not change layout. Excluded a click or key press, then a layout change within 500 ms an accordion opening, a filter applying β€” the reader asked Recorded a layout change during scrolling or from an arriving asset scrolling is not an interaction for this purpose This asymmetry is the whole reason scroll-driven reveals need to be layout-neutral in a way that click-driven ones do not.

Implementation

1. Animate only properties that do not participate in layout

Reserve the box, animate the rest Four reveal implementations. Opacity and transform reveals occupy their box from the start, so nothing below them moves. Height and margin reveals do not, so every element below is laid out again on each frame and the movement is recorded as unexpected layout shift. causes shift? opacity: 0 β†’ 1 no translate + opacity no block-size: 0 β†’ auto yes margin-block-start yes The rule generalises: if the animated property participates in layout, everything below it moves.
/* layout-neutral: the box exists at full size from the start */
.reveal {
  opacity: 0;
  translate: 0 16px;
  animation: rise linear both;
  animation-timeline: view(block);
  animation-range: entry 20% entry 80%;
}

@keyframes rise {
  to { opacity: 1; translate: 0 0; }
}

The element occupies its final box throughout, and nothing below it moves. This is the whole technique; the rest of the guide is about the cases where something else moves anyway.

2. Reserve every media box

The other source: unreserved media An image without dimensions has no box until it loads. Everything below sits higher than it eventually will, and moves down when the image arrives. On a page of reveals this is frequently a larger contributor than any animation, and the fix is one attribute. before the image loads image arrives everything below moves down width + height, or aspect-ratio β€” one attribute, and the shift disappears
<img src="/media/figure.avif" width="1600" height="900" alt="…">
.prose img { inline-size: 100%; block-size: auto; }

With intrinsic dimensions present, the browser computes the box before the bytes arrive. This single attribute pair removes more shift from a typical article than every animation change combined.

3. Reserve space for anything else that arrives late

.embed-slot {
  aspect-ratio: 16 / 9;
  contain-intrinsic-size: auto 400px;
}

Third-party embeds, lazily-hydrated components and deferred sections all insert content after first paint. Each needs a reserved box for the same reason an image does.

4. Avoid content-visibility without an intrinsic size

.section {
  content-visibility: auto;
  contain-intrinsic-size: auto 70vh;   /* not optional */
}

Without the size, a skipped section reports zero height, and rendering it as the reader approaches changes the page height under them β€” a shift caused by an optimisation intended to help.

5. Check the reduced-motion path too

@media (prefers-reduced-motion: reduce) {
  .reveal { animation: none; opacity: 1; translate: none; }
}

The reset must restore the element to its visible state. A reset that leaves opacity: 0 hides content permanently, which is a worse problem than a shift.

Verification

Record a Performance trace while scrolling and look at the Experience track. Layout shift entries are marked there with the elements involved, which is the fastest way to identify what moved.

Then use a PerformanceObserver for layout-shift entries during a scripted scroll, logging the sources. This turns an impression into a list of elements and works on any device.

Compare the result with images blocked. If the shift disappears, the cause is media reservation rather than animation, which redirects the work entirely.

Finally, test on a throttled connection. Shift from late-arriving content is invisible on a fast local build and obvious on a slow one, which is why it survives development.

Edge cases and gotchas

Reveals on elements that are the largest contentful paint candidate. An element whose final form is delayed by a reveal delays LCP. Reveals belong below the fold, where there is scroll to drive them anyway.

scale on a reveal. Scaling does not affect layout, so it is safe from a shift perspective β€” but a scaled element can visually overlap its neighbours, which is a design problem rather than a metric one.

Sticky elements and shift. A sticky element changing height as it becomes stuck relayouts everything below. Fix the height and animate its contents instead.

Fonts arriving late. A font swap changes text metrics and shifts everything below, independently of any animation. size-adjust and matched fallback metrics are the fix, and it is worth doing on any page that is fighting for a CLS score.

Scroll anchoring masking the problem. Browsers try to keep the reading position stable when content above changes, which hides some shift from the reader while still recording it. A good score and a stable-feeling page are not the same thing.

Browser-specific notes

Cumulative Layout Shift is measured by Chromium and reported in its field data; the other engines do not report it. That does not make the underlying problem browser-specific β€” content moving under a reader is equally unpleasant everywhere β€” but it does mean the metric you are optimising is Chromium’s.

Scroll anchoring behaviour differs between engines, with Firefox applying it most assertively. A page that feels stable in Firefox may still be recording shift in Chromium.

Intrinsic sizing from width and height attributes works identically in all three, as does aspect-ratio. There is no engine-specific handling needed for any of the fixes in this guide.

Frequently Asked Questions

Does a transform-based reveal really cause no shift at all?

None. A transform does not affect layout, so surrounding content is laid out as though the element were at its untransformed position throughout. This is the same property that makes transforms compositor-safe, which is why the performance advice and the shift advice point at the same implementations.

What about animating max-height for an expanding section?

It changes layout on every frame, so it shifts everything below. Within 500 milliseconds of a click it is excluded from the metric, which makes it acceptable for an accordion and unacceptable for anything driven by scrolling. For scroll-driven expansion, animate a transform on an inner element inside a reserved box instead.

Is contain: layout enough on its own?

It bounds the scope of a layout to the containing element, which prevents changes inside it from affecting elements outside. That helps a great deal for components whose internals change, and it does not help when the container itself changes size, which is the common reveal case.

How much CLS is acceptable?

The published threshold is 0.1 for a good score, but for animation-caused shift the right target is zero. Every animation-caused shift has a layout-neutral equivalent, so there is no trade to make β€” unlike shift from late-arriving third-party content, where a small residue is sometimes unavoidable.

Does a view transition cause layout shift?

Not directly. During a transition the live DOM is replaced by pseudo-elements, and the metric does not record movement of those. What can cause shift is what happens either side of it β€” content arriving after the new state renders, or a destination page whose media is unreserved. The transition is not the cause but it is frequently where the shift becomes visible, because the reader is watching that region closely.

Is a small amount of animation-caused shift acceptable?

There is no reason to accept any, because every animation that causes shift has a layout-neutral equivalent that looks the same. That is different from third-party content, where a residual amount is sometimes genuinely outside your control. Spending the budget on the part you cannot fix, rather than on the part you can, is the sensible allocation.

How do I find which element shifted?

The layout-shift performance entries carry a sources array naming the nodes that moved and their previous and current rectangles. Logging that during a scripted scroll gives an exact list, which is considerably faster than inferring it from a recording β€” and it works on devices where no profiler is attached.

Does lazy loading make shift more likely?

Only when the box is not reserved. A lazily-loaded image with intrinsic dimensions occupies its space from first layout and shifts nothing when it arrives; the same image without them shifts everything below it, and the delay makes the shift more visible rather than more likely. Reserve the box and lazy loading is purely a benefit.


Up: Core Web Vitals for Scroll & View Transitions