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
| Island | Directive | Shift before fix | Pattern |
|---|---|---|---|
| Pricing toggle (monthly/annual) | client:load | 0.071 | client state differs from server render |
| API status badge | client:only="react" | 0.012 | client-only render |
| Changelog "new" count in header | client:idle | 0.004 | data fetched after hydration |
| Code tabs (npm/pnpm/yarn) | client:visible | 0.038 | client state (saved choice) |
| Feedback widget | client:visible | 0.021 | client-only render |
| Mobile navigation | client:media | 0 | responsive logic, done right |
| Search dialog | on click | 0 | user-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.
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: hiddenin monthly mode. Tab panels havemin-heightequal 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>
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.
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
| Measure | Before | After |
|---|---|---|
| CLS p75, pricing page, mobile (field) | 0.14 | 0.01 |
| CLS p75, docs pages with code tabs | 0.08 | 0.02 |
Islands using client:only | 2 | 0 |
| Islands fetching data after hydration | 2 | 1 (with placeholder) |
| Lab CLS test failures caught in pull requests (6 months) | — | 4 |
Pitfalls & Rollback
client:onlyfor convenience. It guarantees an empty slot in the HTML; server-render a shell instead.- Reading
localStorageduring 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.
Related
- Parent: Cumulative Layout Shift Fixes for Static Sites — all the common causes.
- Deferring Hydration with client:visible in Astro — hydration timing and its side effects.
- Server Islands in Astro — deferred server rendering with fallbacks.
- Fixing CLS from Late-Loading Embeds — the third-party version of the same problem.
- Debugging CLS with the Layout Instability API — finding the shifting element.
Filed underFixes