Fixing Layout Shift from Late-Loaded Components
Many parts of a modern page only take their final shape once JavaScript runs: sliders gather their slides into one frame, tabs hide all but one panel, review widgets fill an empty box, and headers turn sticky. Between the first paint and that moment the page looks different, and when the script finishes, content jumps.
This guide is about those interface components, whatever your platform. It shows how to spot the ones that change layout, how to give each its final size before its script runs, and how to use skeleton placeholders that really match.
The before-and-after problem
A browser paints the page as soon as it has the HTML and CSS it needs. Scripts often run later, especially if they are deferred, loaded by a tag manager or delayed by a performance plugin. Anything a script changes about size or position becomes a layout shift if the visitor could already see it.
Component shifts come in two opposite flavours:
- Collapse. The HTML contains all the content, and the script hides or rearranges some of it. A carousel first shows five slides stacked on top of each other, then JavaScript gathers them into a single frame and the page below jumps up. Tabs show every panel until the script hides all but one.
- Expand. The HTML contains an empty container, and the script fills it. A reviews widget, a "customers also bought" row, a store locator or a live stock message arrives a second later and pushes the content below it down.
Both have the same cure: make the page before JavaScript look, in size and position, the same as the page after JavaScript. The script can still add behaviour, such as swiping or switching tabs, but it should not be the thing that decides how much space a component takes.
Common components and how they shift
| Component | What goes wrong | Stable approach |
|---|---|---|
| Carousel or slider | All slides render stacked, then collapse; or the frame starts at zero height until the script measures slides | CSS sets the frame's aspect ratio and shows only the first slide before the script runs |
| Tabs and accordions | Every panel is visible until the script hides the inactive ones | Inactive
panels carry the hidden attribute in the HTML; accordions can use <details> |
| Review or rating widget | Empty box expands when stars and quotes load | Container
with a min-height matching the loaded widget |
| Recommendations row | Row of products appears after an API call | Skeleton cards of the final size, or render on the server |
| Sticky header | Switches from normal flow to position: fixed on scroll, content
jumps up by the header's height | Use position: sticky, or keep a spacer of the same
height |
| Mega menu or navigation | Menu items reflow into a "More" dropdown once the script measures the width | Decide overflow with CSS or on the server, not by measuring after load |
| Countdown or live counters | Numbers change width as they tick | Fixed width and
font-variant-numeric: tabular-nums |
| Embedded booking or map widget | Frame resizes itself when its content loads | Wrapper with a fixed height or aspect ratio |
Finding components that move
The quickest way to find every component that changes layout is to compare the page with and without JavaScript.
- Take a no-JavaScript view. In Chrome DevTools, open the Command menu (Ctrl+Shift+P, or Cmd+Shift+P on a Mac), type "Disable JavaScript" and reload. What you see is roughly the first paint before scripts run.
- Compare with the normal view. Re-enable JavaScript and reload. Every block that is taller, shorter or in a different place is a candidate. Screenshots of both, side by side, make the differences obvious.
- Confirm with a recording. Record a reload in the Performance panel with CPU throttled. Select each layout shift to see the nodes that moved, and look at the scripts running just before it in the main thread track.
- Check scroll-time shifts. Scroll down slowly while recording. Sticky headers, lazily initialised sliders and components that load when they come into view shift here, not at load.
Match your findings with the CLS Debugger's ranking so you spend time on the components that contribute most to the score. A small badge that wobbles by a few pixels is not worth the same effort as a carousel that collapses 600 pixels at the top of the home page.
Patterns that keep components still
Size the frame, not the content
For a carousel, the outer frame should know its size from CSS alone. If slides are images of a known shape, an aspect ratio does it:
.hero-slider { aspect-ratio: 16 / 7; overflow: hidden; position: relative; }
.hero-slider .slide + .slide { display: none; } /* before the script */
.hero-slider.is-ready .slide + .slide { display: block; }
The first slide shows in its final frame at first paint. When the script is ready it adds is-ready
and positions the other slides using transform, which moves them without affecting layout.
A pure CSS carousel built on scroll-snap-type is even simpler, because the browser lays
it out correctly with no script at all.
Hide inactive content in the markup
<div role="tabpanel" id="tab-delivery">...</div>
<div role="tabpanel" id="tab-returns" hidden>...</div>
Because the second panel is hidden from the first byte of HTML, there is nothing for the script to collapse. The same thinking applies to accordions, "read more" sections and filters.
Skeletons that match
A skeleton is a grey outline shaped like the content that is coming. It only prevents a shift if it is the same size as the final content. A 40-pixel spinner that is replaced by a 320-pixel row of product cards is still a 280-pixel shift.
.rec-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; min-height: 340px; }
.rec-card--skeleton { aspect-ratio: 3 / 4; background: #eee; border-radius: 8px; }
@media (max-width: 600px) { .rec-row { grid-template-columns: repeat(2, 1fr); min-height: 620px; } }
If the number of items can vary, reserve space for the usual count and let a smaller result leave a little space at the end rather than letting a larger one push past the reserved area.
Sticky headers
A header that becomes position: fixed when the visitor scrolls leaves the normal flow,
so the content that was below it jumps up by the header's height. Scrolling does not exempt a shift from
counting. position: sticky avoids the problem because the element keeps its place in the
flow. If you need a fixed header, give the page a top padding equal to the header's height from the start,
so nothing changes when it detaches.
Off-screen sections
The CSS content-visibility: auto property skips rendering off-screen sections to save
time. Pair it with contain-intrinsic-size, preferably with the auto keyword
so the browser remembers the real size once it has rendered a section. A poor size estimate can make
the page's height change as sections render.
Load more, filters and infinite scroll
Shifts within half a second of a click or key press are not counted, because the visitor asked for a change. That exemption is often lost by components that fetch data first. A "Load more" button that waits 800 milliseconds for the server before adding products produces a shift that counts.
- Respond straight away. On click, immediately add skeleton cards of the right size, then fill them when the data arrives. The layout change happens inside the input window and the later fill does not move anything.
- Add below, never above. New items appended after the visible ones do not move what the visitor is looking at. Items inserted above, such as a "new posts" strip at the top of a feed, push everything down.
- Keep filter panels stable. A product count that changes from "12 items" to "1,204 items" can widen a heading and wrap it. Give counts a fixed space.
- Remove the "Load more" button in place. If the button disappears when there is nothing more to load, replace it with a message of the same height.
The fix, step by step
Step 1
List components that change after JavaScript runs
Load each main template with JavaScript disabled in Chrome DevTools, then with it enabled, and take screenshots of both. Every block that changes height, position or visibility goes on a list with its template, its script and whether it collapses or expands. Add the components the CLS Debugger names as moving, then sort the list so the largest shifts, and those above the first screen, come first.
Step 2
Give every widget container its final size in CSS
For each expanding component, measure its loaded height at your main breakpoints
and give the container a min-height or aspect-ratio in your main stylesheet.
Do this in CSS that loads with the page, not in the widget's own script. For third-party widgets such
as reviews, booking tools or maps, wrap them in your own element and size that wrapper, since you cannot
always control what they inject.
Step 3
Render the starting state in HTML and CSS
For collapsing components, make the markup match the state the script will produce. Show
only the first carousel slide with CSS until the script adds a ready class, set the hidden
attribute on inactive tab panels in the template, and render accordions closed. Where possible, replace
a scripted component with native HTML, such as <details> for accordions or a scroll-snap
carousel, which lays out correctly without any script.
Step 4
Replace spinners with sized skeletons
Anywhere content arrives from an API, such as recommendations, recently viewed items or search results, swap loading spinners for skeleton placeholders that match the final layout: the same number of cards, the same grid and the same height at each breakpoint. For buttons that fetch more content, insert the skeletons immediately on click so the space opens within the half-second input window, then fill them when the data comes back.
Step 5
Keep sticky and expanding parts out of the flow
Change headers that switch to position: fixed on scroll to position: sticky,
or add a permanent spacer equal to the header height. Make dropdowns, mega menus, tooltips and floating
chat buttons use absolute or fixed positioning so they open over the page. Animate panels with transform
and opacity, not height or top, and give live counters a fixed
width with tabular numbers.
Step 6
Retest with slow JavaScript
Run the CLS Debugger again, then record reloads in DevTools with the CPU throttled four to six times, because slow devices show the gap between first paint and script completion most clearly. Compare the no-JavaScript and JavaScript screenshots again; they should now line up. Scroll the full page while recording to catch sticky headers and lazily started components, and check both a phone-sized and a desktop viewport.
Stopping new components from shifting
- Give each component a size contract. In your component library or pattern documentation, record how much space each one reserves at each breakpoint before it loads.
- Review performance plugin settings. Options that delay all JavaScript until the first interaction make the pre-script state last longer, which exposes every component that relies on its script for layout.
- Add a no-JavaScript screenshot to reviews. A quick comparison with scripts off catches most new component shifts before release.
- Vet third-party widgets. Before adding a new embed, check whether it lets you set a fixed height and whether it resizes itself after load.
1. Why does my carousel cause layout shift?
Usually because the page is painted before the carousel script runs. Until then, the slides may be stacked vertically, making the section very tall, or the frame may have no height at all because the script is meant to measure the slides. When the script finishes, the section snaps to its final size and moves everything below it. Give the frame a size in CSS and show only the first slide until the script is ready.
2. Do skeleton screens fix CLS?
Only if they are the same size as the content that replaces them. A skeleton that reserves roughly the right shape, such as four grey product cards at the same height as the real ones, prevents a shift. A small spinner or a single grey bar that is swapped for a much taller block of content still causes a shift, just a slightly smaller one. Measure the loaded component at each breakpoint and make the skeleton match it.
3. Does a sticky header count as layout shift?
It can. A header that is in the normal page flow and then switches to fixed positioning as you scroll stops taking up space, so the content beneath it jumps up by the header height. Shifts during scrolling are not excluded, so that jump counts. Using position sticky, which keeps the header in the flow, avoids it. With a fixed header, reserve its height with padding on the page from the start.
4. Are layout changes after clicking a button counted?
Changes that happen within 500 milliseconds of a click, tap or key press are excluded, because the visitor expected the page to change. The catch is that many components wait for data from a server before changing anything, and if that takes longer than half a second the resulting shift does count. Respond to the click immediately by showing a correctly sized placeholder, then fill it in when the data arrives.
5. How do I stop a third-party reviews widget from moving my page?
Wrap the widget in an element you control and give that element a minimum height equal to the widget once it has loaded, measured on mobile and desktop. The widget then loads inside space that already exists. If its height varies a lot, for example depending on the number of reviews, choose a layout option in the widget settings with a fixed height, or place it lower on the page where the shift affects fewer visitors.
6. Can CSS animations cause layout shift?
Animations that change layout properties such as height, width, top, left or margin move the surrounding content, and if they run without the visitor triggering them they can count as shifts. Animations using transform, such as translate and scale, and opacity changes do not affect layout and are not counted. When building sliding panels, expanding sections or entrance effects, animate transform and opacity instead. It also tends to run more smoothly on slower phones.
7. Should I use content-visibility auto to speed up my page?
It can reduce rendering work on long pages by skipping sections that are off-screen, but it needs a size estimate to avoid problems. Set contain-intrinsic-size with the auto keyword and a sensible starting value, so the browser uses your estimate until the section first renders and then remembers the real size. Without an estimate the skipped sections count as zero height and the page and scrollbar change size as they render.
The CLS Debugger records every layout shift while the page loads, names the elements involved, calculates the cumulative score and ranks shifts by their contribution, so you can tell which widgets and sections to fix first.
Open CLS Debugger Fixes โ