Preventing Layout Shift with Skeleton Placeholders

Islands hydrate after the shell paints, and if the mount point does not already occupy the island’s eventual size, the moment it becomes interactive it grows and shoves every element below it down the page. That jump is Cumulative Layout Shift, one of the three Core Web Vitals, and it is entirely avoidable — the fix is to make the reserved box the correct size before any JavaScript runs. This guide is a focused procedure for reserving island mount-point dimensions with min-height, aspect-ratio, content-visibility, and contain-intrinsic-size, and then proving CLS is under the 0.1 threshold. It sits under fallback UI and skeleton strategies for streaming islands, which covers the broader question of what to render while an island is still inert.

Prerequisites


Where the Shift Comes From

The shell paints a collapsed fallback; hydration expands it; content below jumps. Reserving the mounted height up front means the swap changes pixels inside the box, never the box itself.

Layout shift on hydration versus a reserved mount point Top row: a short fallback box with a footer directly beneath; after hydration the box grows and the footer is pushed down, producing layout shift. Bottom row: the container already reserves the full mounted height, so after hydration the footer stays exactly where it was. WITHOUT reservation — footer shifts fallback (short) footer island (mounted, tall) footer pushed down CLS > 0 WITH reservation — footer stable reserved box min-height: 80px island fills same box footer stays CLS = 0

Implementation Steps

Step 1 — Measure the mounted size as your reservation target

Goal: Know the exact height (and, ideally, aspect ratio) the island occupies once interactive.

// Run in the console after the island hydrates, on each target viewport.
const el = document.querySelector('[data-island-id="review-carousel"]');
const r = el.getBoundingClientRect();
// Record height per breakpoint; the reservation must cover the tallest case
// the box can occupy before user interaction changes it.
console.log(`mounted: ${r.width.toFixed(0)}×${r.height.toFixed(0)}`);

Expected output: A stable height per breakpoint, e.g. 360×420 on mobile, 720×300 on desktop. These become your min-height/aspect-ratio values.


Step 2 — Reserve the box in the static shell

Goal: Make the fallback container occupy the mounted size before hydration.

---
// ReviewCarousel wrapper — server-rendered. The reservation lives on the
// container, not the island, so the box is correct before any JS runs.
---
<div
  data-island-id="review-carousel"
  class="island-reserve"
>
  
  <div class="skeleton-card" aria-hidden="true"></div>
</div>
/* Reserve the eventual size. aspect-ratio handles responsive width→height;
   min-height guards the case where content is shorter than expected.
   Because these apply to the container, the hydration swap never resizes it. */
.island-reserve {
  min-height: 420px;              /* tallest pre-interaction height (mobile) */
  aspect-ratio: 16 / 9;           /* keeps width→height stable across breakpoints */
}
@media (min-width: 768px) {
  .island-reserve { min-height: 300px; }
}

Expected output: With JavaScript disabled, the reserved container already occupies the mounted footprint; the footer sits where it will remain after hydration.

The reason to put the reservation on the wrapper rather than the island itself is that the wrapper exists in the server-rendered HTML from the first byte, whereas the island’s own root element is only sized once its component renders. min-height and aspect-ratio together cover two different cases: aspect-ratio derives a height from the (known) width, which is ideal for media-like islands whose proportions are fixed; min-height is the floor for islands whose height is content-driven and could otherwise collapse below the mounted size. Setting both means the box never renders shorter than the island will need, and CLS is scored on exactly that difference — the delta between the fallback’s laid-out height and the mounted height. Reduce the delta to zero and the shift score is zero regardless of how long hydration takes.

A useful mental rule: reserve for the tallest state the box can reach before the user interacts with it. Over-reserving by a few pixels leaves a sliver of empty space, which is invisible and free; under-reserving by any amount scores CLS every single load. When in doubt, round the reservation up.


Step 3 — Stabilise off-screen islands with contain-intrinsic-size

Goal: Prevent scroll-height jumps for below-the-fold islands rendered with content-visibility: auto.

/* content-visibility: auto skips rendering off-screen content for speed, but
   the browser would treat skipped content as zero-height and jump the scroll
   position when it enters the viewport. contain-intrinsic-size supplies the
   placeholder dimensions so the scroll height is stable = no CLS on scroll. */
.island-reserve.below-fold {
  content-visibility: auto;
  contain-intrinsic-size: 720px 420px;   /* width height = the mounted size */
}

Expected output: Scrolling down to a below-the-fold island renders it without any jump in the scrollbar thumb or the position of content beneath it.

content-visibility: auto and CLS have a subtle relationship worth spelling out. The property is a rendering optimisation — it lets the browser skip layout and paint for content outside the viewport, which cuts the work hydration has to compete with. But that skipped content is treated as having zero size unless you tell the browser otherwise, so without contain-intrinsic-size the page’s scroll height is wrong until each island scrolls into view and suddenly acquires real height, jerking everything below. contain-intrinsic-size supplies the placeholder dimensions the browser uses while content is skipped, so the total scroll height is correct from the start. The two properties are almost always used as a pair for this reason: one buys the performance, the other pays back the layout stability it would otherwise cost.


Step 4 — Match the skeleton to the mounted layout

Goal: Ensure the static skeleton and the mounted island share dimensions so the swap shifts nothing internal either.

/* The skeleton must occupy the same grid/height as the mounted island so the
   content swap is pixel-stable, not just the outer box. */
.skeleton-card {
  height: 100%;
  border-radius: 8px;
  background: linear-gradient(90deg,
    currentColor 0%, transparent 40%, currentColor 80%);
  opacity: 0.08;                 /* faint; adapts to light & dark themes */
}

Expected output: Toggling between the skeleton and the mounted island in DevTools shows no reflow of surrounding elements and no internal jump.


Verification

  1. Layout Instability API. Observe shifts directly and attribute them:

    // Log every layout shift with its score and the nodes that moved.
    new PerformanceObserver((list) => {
      for (const e of list.getEntries()) {
        if (e.hadRecentInput) continue;            // ignore user-driven shifts
        console.log('CLS chunk', e.value.toFixed(4),
          e.sources.map((s) => s.node));           // which nodes shifted
      }
    }).observe({ type: 'layout-shift', buffered: true });

    After reservation, no layout-shift entries should reference the island container or the footer beneath it during hydration.

  2. Lighthouse CLS. Run Lighthouse (throttled) and confirm the Cumulative Layout Shift metric is under 0.1 (ideally 0). Use the “Avoid large layout shifts” audit to see if the island container is still listed as a shift source.

  3. Throttled hydration test. In the Performance panel with 4× CPU throttle, record a load and check the Experience track for red CLS bars during the hydration window. Reserved islands produce none.

  4. Disabled-JS baseline. Load with JavaScript disabled. The reserved box must already match the mounted footprint; if the page is visibly shorter without JS, the reservation is too small.


Troubleshooting

CLS still fires even though min-height is set

Root cause: The mounted island is taller than the reserved min-height on some viewport — usually because the measurement in Step 1 was taken on one breakpoint but the island grows on another (longer text, more items, larger fonts).

Fix: Measure the tallest pre-interaction state on every breakpoint and set per-breakpoint reservations, or switch to aspect-ratio so the height scales with width. Reserve for the worst case; a box slightly too tall costs nothing, a box too short scores CLS.

Below-the-fold island jumps the scroll position as I scroll to it

Root cause: content-visibility: auto is set without contain-intrinsic-size, so the browser sizes the skipped content at zero until it renders, then expands it — moving everything below.

Fix: Add contain-intrinsic-size: <width> <height> matching the mounted size. This gives layout a stable placeholder height while the content is skipped, so entering the viewport does not change the scroll height.

The skeleton is the right size but content inside the island still jumps on hydration

Root cause: The outer box is reserved, but the island renders an internal layout (image, heading, controls) at different positions than the skeleton, so children shift even though the container does not.

Fix: Build the skeleton with the same internal grid and element heights as the mounted island — reserve image aspect ratios and text-line heights, not just the outer box. Match the skeleton’s structure to the real component so the swap is pixel-stable throughout.


← Back to Fallback UI and Skeleton Strategies for Streaming Islands