How Scroll-Triggered Animations Cause CLS

Your hide-on-scroll header might be silently failing CLS. Here is why, and how to fix it.

Arjen Karel Core Web Vitals Consultant
Arjen Karel - linkedin
Last update: 2026-03-26

Whenever I get asked to do an audit of the Cumulative Layout Shift I obviously start with shifts during the loading phase. I check for images without dimensions, injected ads, font swaps (FOUT).

But not all CLS happens during the page load. Sometimes it is triggered by interactions like scrolling. Those are hard to catch because Lighthouse does not scroll and you need to know exactly where to look.

Last reviewed by Arjen Karel on March 2026

In a recent audit I added CoreDash RUM data and saw a suspicious pattern. A #header element seemed to cause much more layout shift than normal.

coredash cls scroll header

Turns out what we are seeing here is a fixed header that hides when the user scrolls down and reappears when they scroll up. It is a pattern I see quite often and if not done right it will cause a layout shift on every scroll direction change.

Why scroll-triggered shifts are different

Normally CLS does not happen after an interaction. CLS measures unexpected layout shifts and after an interaction you would expect the layout to change. That is why the CLS metric has an automatic grace period of 500ms. Layout shifts that occur within 500 milliseconds of a user interaction are excluded from the CLS score. The browser handles this by setting and checking a hadRecentInput flag on these shifts.

The problem is that this only applies to discrete events like clicks, taps, and key presses. According to the Layout Instability specification, scroll is explicitly not an excluding input. Neither are drags or pinch-zoom gestures. Only discrete events get the 500ms grace period.

This means every layout shift triggered by a scroll event counts toward your CLS score.

According to the 2025 Web Almanac, 40% of mobile pages still use non-composited animations. These are animations on properties like top, height, or margin that trigger layout recalculation on every frame. If any of those fire during a scroll, every single frame counts toward your CLS.

The hide-on-scroll header problem

Let's start with the problem I found today, the 'hide-on-scroll header problem'. A fixed header uses JavaScript to toggle visibility on scroll. The implementation looks like this:

/* CSS */
#header {
  position: fixed;
  top: 0;
  transition: top 0.3s ease;
}

/* JavaScript */
window.addEventListener('scroll', () => {
  if (scrollingDown) {
    header.style.top = '-80px';  // hide
  } else {
    header.style.top = '0px';    // show
  }
});

The layout shift is caused by the combination of transition: top 0.3s ease and changing header.style.top in JavaScript.

Here is why: top is a layout property. When the browser processes a change to top, it runs the full rendering pipeline: Style, Layout, Paint, Composite. On every animation frame during that transition, the browser recalculates layout. Each recalculation produces a new layout shift entry.

With each scroll direction change the CLS counter keeps climbing for as long as the user interacts with the page.

I built a demo reproducing this exact behavior from a live site I was auditing and recorded a video. The total CLS climbed past 0.1 after some scrolling. Now granted: this level of scrolling does not often happen in real life but the mechanics do not change. And I have seen this layout shift push sites over that edge and have their total CLS fail.

Which CSS properties can cause layout shifts when animated?

For an animation to cause CLS it needs to trigger Layout. The browser's rendering pipeline has three main stages after style calculation: Layout, Paint, and Composite. Properties that only affect the Composite stage never cause layout shifts. For more on how CSS transitions cause layout shifts, see the companion article.

Properties that trigger layout (and cause CLS when animated):

top, left, right, bottom, width, height, margin, padding, border-width, font-size

All of these change the geometry or position of an element. The browser must recalculate the position of the element (and possibly of other elements as well).

Properties that skip layout entirely (and are safe for animation):

transform and opacity run on the compositor thread. They bypass both layout and paint. The browser handles them on the GPU (this is why these animations work even while the main thread is busy). Because they never trigger layout recalculation, they never produce layout shift entries.

Replace layout properties with transforms

The fix for the hide-on-scroll header is simple. Replace the top animation with transform: translateY().

/* CSS - BEFORE (causes CLS) */
#header {
  position: fixed;
  top: 0;
  transition: top 0.3s ease;
}

/* CSS - AFTER (no CLS) */
#header {
  position: fixed;
  top: 0;
  transition: transform 0.3s ease;
}

/* JavaScript - BEFORE */
header.style.top = '-80px';

/* JavaScript - AFTER */
header.style.transform = 'translateY(-80px)';

This gives the same result: the header slides up and disappears. But transform: translateY() runs entirely on the compositor and never causes a layout shift.

The same principle applies to any scroll-triggered animation:

  • Sliding panels: use transform: translateX() instead of left or right
  • Expanding elements: use transform: scaleY() instead of height
  • Fading elements: use opacity instead of visibility combined with height changes

Other scroll-triggered patterns that cause CLS

The hide-on-scroll header is something I see quite often but there are more. Any JavaScript that changes a layout property in response to scroll events will produce a layout shift. For example:

Parallax effects using top or margin-top. Replace with transform: translateY() or use the CSS perspective and translateZ approach for pure-CSS parallax.

Sticky navigation that changes height on scroll. A navigation bar that shrinks from 80px to 50px when the user scrolls past a threshold. If the height change is animated with transition: height, replace it with transform: scaleY().

Scroll-linked progress bars using width. A reading progress indicator that grows from left to right by animating width. Use transform: scaleX() with transform-origin: left instead.

Modern alternative: CSS Scroll-Driven Animations. The animation-timeline: scroll() API lets you drive animations from scroll progress without any JavaScript. Because these animations use transform and opacity by default and run on the compositor, they never cause layout shifts. Browser support covers Chrome 115+ and Edge 115+, with Safari still in development. For scroll-linked effects like parallax, progress bars, and reveal animations, this is the cleanest solution. If you want to ditch JavaScript scrolling entirely, the scroll-driven animations API is the way forward.

Why Lighthouse does not catch this

Lighthouse runs a simulated page load. It does not scroll the page. It does not interact with the page after load. This means scroll-triggered layout shifts are completely invisible in lab testing.

The only way to catch these shifts is with Real User Monitoring. Field data captures the full page lifecycle, including every layout shift that happens while the user scrolls, clicks, and navigates. If your CrUX CLS score is worse than your Lighthouse CLS score, scroll-triggered animations are one of the first things to investigate. CoreDash tracks CLS from real visitors, including every shift caused by scrolling, so you can see exactly which elements are responsible.

How to find scroll-triggered layout shifts

Open Chrome DevTools. Go to the Performance panel. Start the recorder (the circle icon) then scroll the page up and down several times. Stop the recording. Look for purple diamonds in the Layout Shifts track. If you see a cluster of shifts that align with your scroll activity, you have a scroll-triggered CLS problem. You can also use the Core Web Vitals Visualizer Chrome extension to see CLS in real time as you scroll.

devtools scroll layout shift

The rule of thumb

If it moves on scroll, animate it with transform. If it fades on scroll, animate it with opacity. These properties are compositor-only.

For JavaScript: check your scroll event handlers, IntersectionObserver callbacks, and any JavaScript that changes element styles in response to scroll position. If it uses top, left, margin, width, height, or padding, replace it with the equivalent transform.

About the author

Arjen Karel is a web performance consultant and the creator of CoreDash, a Real User Monitoring platform that tracks Core Web Vitals data across hundreds of sites. He also built the Core Web Vitals Visualizer Chrome extension. He has helped clients achieve passing Core Web Vitals scores on over 925,000 mobile URLs.

17 years of fixing PageSpeed.

I have optimized platforms for some of the largest publishers and e-commerce sites in Europe. I provide the strategy, the code, and the RUM verification. Usually in 1 to 2 sprints.

View Services
How Scroll-Triggered Animations Cause CLSCore Web Vitals How Scroll-Triggered Animations Cause CLS