CLS from Client-Side Rendered Components

Static sites mostly avoid layout shift by construction: the HTML arrives complete, with images sized and text in place. The exception is anything rendered or changed by JavaScript after the page appears — an Astro island that fetches data, a React widget that reads the reader's saved preferences, a "live" status badge, a pricing toggle that remembers annual versus monthly, a comments count. Each of these can occupy one amount of space in the static HTML and another after it runs, and the difference moves everything below it.

This guide finds and fixes shifts caused by client-rendered components on an Astro marketing and documentation site with seven islands. It covers the four patterns that caused shifts — client-only rendering, client-side state that differs from the server render, data fetched after hydration, and responsive logic in JavaScript — and the fix for each. It is part of Cumulative Layout Shift Fixes for Static Sites, and ties into JavaScript Hydration & Partial Rendering.

Prerequisites

  • Field CLS attribution pointing at component elements, as in Debugging CLS with the Layout Instability API.
  • A list of islands or client components and their hydration directives.
  • DevTools with network and CPU throttling for reproduction.

The Seven Islands and What They Did

IslandDirectiveShift before fixPattern
Pricing toggle (monthly/annual)client:load0.071client state differs from server render
API status badgeclient:only="react"0.012client-only render
Changelog "new" count in headerclient:idle0.004data fetched after hydration
Code tabs (npm/pnpm/yarn)client:visible0.038client state (saved choice)
Feedback widgetclient:visible0.021client-only render
Mobile navigationclient:media0responsive logic, done right
Search dialogon click0user-initiated

The pricing toggle and the code tabs caused most of the field CLS on their templates; the others were small individually but added up on pages with several.

Layout shift caused by each client-rendered component before fixes Horizontal bars of the layout shift value each island caused on its template. Pricing toggle 0.071. Code tabs 0.038. Feedback widget 0.021. API status badge 0.012. Changelog count 0.004. Mobile navigation and search dialog 0. Shift contributed by each island Pricing toggle 0.071 Code tabs 0.038 Feedback widget 0.021 API status badge 0.012 Changelog count 0.004
Measured with a PerformanceObserver on throttled mobile loads, median of ten runs per template.

Pattern 1: Client-Only Rendering

client:only tells Astro to skip server rendering and render the component only in the browser. The static HTML contains nothing where it will be, so when it mounts, it pushes content down by its full height. The status badge and the feedback widget used it because they read browser-only APIs at module scope.

The fix is to server-render a static shell. Move browser-only reads into effects so the component can render on the server, and give the server render the same dimensions as the final state:

export default function StatusBadge() {
  const [status, setStatus] = useState<'unknown' | 'ok' | 'degraded'>('unknown');
  useEffect(() => { fetch('/status.json').then((r) => r.json()).then((d) => setStatus(d.status)); }, []);
  return <span className={`badge badge-${status}`}>{label[status]}</span>;   // same size in every state
}

With client:idle instead of client:only, the HTML contains the badge in its "unknown" state, and the later update changes only its text and colour, not its size. Where a component truly cannot render on the server, reserve its space with a wrapper and CSS: <div style="min-height: 13.5rem"> around the feedback widget, sized to its collapsed height.

Pattern 2: Client State That Differs From the Server Render

The pricing toggle defaulted to "monthly" on the server, but on hydration it read the reader's saved preference from localStorage and, for returning visitors who had chosen "annual", re-rendered with annual prices — which included an extra "save 20%" line in each pricing card, making every card taller. Code tabs did the same with a saved package manager, and the tab panels had different heights.

Three fixes, from best to acceptable:

  • Make states the same size. The "save 20%" line now exists in both states, with visibility: hidden in monthly mode. Tab panels have min-height equal to the tallest panel's height, computed at build time from the code block line counts.
  • Apply the saved state before first paint. A tiny inline script in the <head> reads the preference and sets an attribute on <html> (data-billing="annual"), and CSS shows the right prices from the start. The island then hydrates into the state the HTML already shows. This is the same technique used for dark mode without a flash.
  • Delay the switch until interaction. If the state cannot be known before paint, keep the server default until the reader interacts.
<script>
  try { document.documentElement.dataset.billing = localStorage.getItem('billing') || 'monthly'; } catch {}
</script>
Why saved client state shifts the layout and how the fix avoids it Two panels. Before, the server rendered monthly prices, hydration read annual from localStorage and re-rendered taller cards with an extra line, shifting everything below by 0.071. After, an inline head script sets data-billing before first paint, CSS shows the matching prices from the first frame, both states have the same height, and hydration changes nothing visible. Server default versus saved client state Before server renders monthly hydration reads annual cards re-render taller content below shifts CLS contribution 0.071 After head script sets data-billing CSS shows matching prices states have equal height hydration is invisible CLS contribution 0
The inline script is 120 bytes and runs before first paint, the same technique used for theme toggles.

Pattern 3: Data Fetched After Hydration

The header's changelog count ("3 new") fetched a JSON file after hydration and inserted a pill next to the "Changelog" link, widening the navigation and wrapping it onto two lines on narrow screens. On a static site the simplest fix is to not fetch at all: the count is known at build time, so the build writes it into the HTML. Where data must be fresher than the last build, render a placeholder of the final size — a pill with a fixed width and a neutral label — and replace only its contents.

A general rule helped the team: anything that is known at build time is rendered at build time. Fetching after hydration is reserved for data that genuinely changes between deploys, and every such component has a fixed-size placeholder.

Pattern 4: Responsive Logic in JavaScript

Components that decide their layout in JavaScript from window.innerWidth render one layout on the server (which has no window) and another in the browser. The mobile navigation avoided this: it is rendered in the HTML for all widths and shown or hidden with CSS media queries, and client:media="(max-width: 50em)" only hydrates it where it is visible. Compare the anti-pattern of rendering a desktop menu on the server and swapping it for a hamburger after hydration on mobile, which on another site caused a 0.05 shift on every mobile page view. Layout belongs in CSS; JavaScript should add behaviour to layout that is already correct.

Client-rendered layout shift patterns and fixes A matrix of four patterns. Client-only rendering is fixed by server-rendering a same-size shell or reserving space with min-height. Client state that differs from the server render is fixed with equal-size states or an inline head script applying the state before paint. Data fetched after hydration is fixed by rendering build-time data or a fixed-size placeholder. Responsive logic in JavaScript is fixed by doing layout in CSS and hydrating with client:media. Four patterns and their fixes Pattern Fix Example here client:only render same-size shell or min-height status badge, feedback Saved client state equal sizes or head script pricing toggle, code tabs Fetch after hydration build-time data or placeholder changelog count JS responsive layout CSS layout + client:media mobile navigation
Each fix keeps the first frame and the hydrated frame the same size.

Preventing Regressions

Component shifts come back whenever someone adds a new island. Two guards help. A Playwright test loads each template with a PerformanceObserver injected before navigation, waits for hydration and idle, and fails if the summed layout shift exceeds 0.01 — cheap and deterministic enough for every pull request, as in End-to-End Tests for Static Sites with Playwright. And a code review checklist item: any new client:only, any read of localStorage or window during render, and any fetch after hydration needs a note on how its space is reserved.

Measured Impact

MeasureBeforeAfter
CLS p75, pricing page, mobile (field)0.140.01
CLS p75, docs pages with code tabs0.080.02
Islands using client:only20
Islands fetching data after hydration21 (with placeholder)
Lab CLS test failures caught in pull requests (6 months)—4

Pitfalls & Rollback

  • client:only for convenience. It guarantees an empty slot in the HTML; server-render a shell instead.
  • Reading localStorage during render. Move it to an effect or an inline head script.
  • Placeholders that are almost the right size. A few pixels difference still shifts everything below.
  • Auto-expanding content after data loads. Expand on user input instead; input-driven shifts are excluded.
  • Rollback: each fix is local to one component; reverting it restores the previous behaviour without affecting others.

Conclusion

On a static site, layout shift comes almost entirely from components that render or change after the HTML appears. Server-render a shell at the final size, apply saved client state before first paint, render build-time data at build time, keep layout in CSS, and test each template for shifts after hydration. On this site that took the pricing page's field CLS from 0.14 to 0.01 without removing any feature.

FAQ

Why do client-rendered components cause layout shift?

Because the static HTML contains either nothing or a differently sized placeholder where the component will render. When JavaScript runs and the component mounts or re-renders with real data, it takes up a different amount of space and pushes the content below it.

Does server-rendering a component in Astro prevent the shift?

Usually, because the HTML already contains the component's markup at its final size. Shifts remain if the component renders differently on the client, for example by reading localStorage, the viewport width or the current time, or if client-only components use client:only and render nothing on the server.

What is the safest placeholder size?

The component's most common final size, set with min-height or aspect-ratio in CSS. If the final size varies, reserve the most likely size and let the component grow below the fold or expand only in response to user input, which is not counted as layout shift.

Are shifts caused by user clicks counted in CLS?

No. Shifts within 500 milliseconds of a discrete user input, such as a click, tap or key press, are excluded. Expanding an accordion on click is fine; expanding it automatically after data loads is not.

Filed underFixes