In Svelte 5, publish ResizeObserver sizes into a $state object from an action or attachment, derive layout choices with $derived, and never write back to the observed element's size from an $effect that reads that state — that is the loop that makes size-driven components jitter.
Problem / Scenario Context
A Svelte 5 dashboard card chooses between a "compact" and "full" layout based on its own width, and a chart inside it redraws at the card's exact size. The first version used bind:clientWidth={width} on the card and an $effect that set the chart's height to width * 0.5. When the card sits in a grid that sizes rows by content, the chart's new height changes the row height, which changes the card's width through a scrollbar appearing, which changes the chart height again. The layout jitters for several frames on every window resize, and the console sometimes shows the resize loop error.
Svelte's built-in dimension bindings are convenient, but they hide the mechanism. The Svelte Actions & Solid Primitives topic introduces the action model; this page does the same for size.
Mechanics Explanation
Svelte's bind:clientWidth, bind:clientHeight, bind:contentRect and related bindings are implemented with a ResizeObserver under the hood in current versions. They are fine for reading. Trouble comes from the combination of three facts:
ResizeObservercallbacks run inside the rendering steps, after layout, before paint — described in ResizeObserver and the update-the-rendering steps.- Svelte 5 flushes
$effects in a microtask after state changes. When a size update sets state inside the callback, the dependent effect runs soon after — and its DOM writes can change layout again. - If that write changes the observed element's size, the observer reports again, the state changes again, and the cycle repeats — either within one frame (up to the browser's depth limit) or across frames, which is the jitter.
The fix has two parts: make the state derive layout decisions rather than drive writes back into the observed element, and quantise the size so that sub-pixel changes do not produce new state.
Comparison Table: Ways to Get Size in Svelte 5
| Approach | Box model | Shares an observer? | Quantisation | Write-back risk |
|---|---|---|---|---|
bind:clientWidth |
padding box, integer | framework-managed | integer px | high if effects write size |
bind:contentRect |
content box, float | framework-managed | none | high |
Custom action + $state |
any box, your choice | yes, if pooled | your choice | controlled |
Attachment {@attach} + $state |
any | yes, if pooled | your choice | controlled |
| CSS container queries | n/a | no JS | n/a | none |
Minimal Reproducible Example
<script lang="ts">
let width = $state(0);
let chart: HTMLDivElement;
$effect(() => {
chart.style.height = `${width * 0.5}px`; // writes layout from observed size
});
</script>
<div class="card" bind:clientWidth={width}>
<div bind:this={chart} class="chart"></div>
</div>
In a grid whose rows size to content, resizing the window makes the card flicker between two widths.
Production-Safe Solution
Measure with an action into a small reactive object, quantise, derive decisions, and apply sizes only to things that do not change the observed box — or let CSS do it.
// size.svelte.ts — reactive size store fed by a shared ResizeObserver
type Size = { inline: number; block: number };
const targets = new Map<Element, (s: Size) => void>();
let ro: ResizeObserver | null = null;
function shared(): ResizeObserver {
return ro ??= new ResizeObserver((entries) => {
for (const e of entries) {
const box = e.contentBoxSize[0];
targets.get(e.target)?.({ inline: box.inlineSize, block: box.blockSize });
}
});
}
export function createSize(step = 8) {
const size = $state<Size>({ inline: 0, block: 0 });
const action = (node: HTMLElement) => {
targets.set(node, (s) => {
// Quantise: ignore sub-step changes so jitter cannot become new state.
const inline = Math.round(s.inline / step) * step;
const block = Math.round(s.block / step) * step;
if (inline !== size.inline) size.inline = inline;
if (block !== size.block) size.block = block;
});
shared().observe(node);
return { destroy() { shared().unobserve(node); targets.delete(node); } };
};
return { size, action };
}
<script lang="ts">
import { createSize } from './size.svelte';
const { size, action } = createSize(8);
const compact = $derived(size.inline < 480); // a decision, not a write
</script>
<div class="card" class:compact use:action>
<!-- The chart sizes itself with CSS; JS only passes the width it should draw at. -->
<Chart width={size.inline} />
</div>
<style>
.card { container-type: inline-size; }
.card :global(.chart) { aspect-ratio: 2 / 1; width: 100%; } /* height from CSS, not an effect */
</style>
The height now comes from aspect-ratio, resolved by layout in the same pass as the width, so there is no write-back. The component's JavaScript only derives compact and hands the chart a width for its drawing resolution. Quantising to 8 px means a scrollbar flickering by a few pixels does not produce new state at all.
When You Must Write a Size
Sometimes a size really has to be applied from script — a canvas backing store, or a third-party widget that only accepts pixel dimensions. Keep those writes safe:
- Write to something that is not observed, or that is deeper than the observed element. A canvas's
widthattribute sets its backing store, not its CSS box, so it does not re-trigger the observer. - Write in the same rendering pass when it must not flicker: do it directly in the action's observer callback, not in a Svelte
$effect, which runs later. - Make writes idempotent: compare with the last value written and skip if unchanged.
// Canvas sizing directly in the observer callback: same frame, no loop.
targets.set(canvas, ({ inline, block }) => {
const dpr = devicePixelRatio;
const w = Math.round(inline * dpr), h = Math.round(block * dpr);
if (canvas.width !== w) canvas.width = w; // backing store only — not the CSS box
if (canvas.height !== h) canvas.height = h;
});
The canvas-specific details are in resizing a canvas with ResizeObserver without blurring.
Edge Cases
$effect timing. Effects run after the DOM has been updated, in a microtask — they do not run inside the rendering steps. A size-driven write inside an effect always lands one step behind the observer that produced the size, which is where visible jitter comes from.
Initial size of zero. Before the first observer callback, the state is 0. Derived decisions like compact will be true for a moment; if the server-rendered markup assumed "full", hydration will flip it. Either render the SSR default to match the zero-size decision, or initialise from a sensible guess.
Many cards. One ResizeObserver for all cards (the shared instance above) is cheaper than one per card, and a single callback delivers all changed cards together, so their state updates batch into one Svelte flush.
Container queries first. Many "compact vs full" layouts need no JavaScript: container-type: inline-size on the card and @container (width < 480px) rules in its styles. Keep the JavaScript path for decisions CSS cannot make, such as choosing a different child component.
Verification Steps
- Resize the window slowly and quickly; the card should switch layouts once at the breakpoint with no flicker.
- Watch the console for the ResizeObserver loop error during resizes.
- Log state changes; with quantisation, a scrollbar toggle should not produce new values.
- Count observer instances in a heap snapshot: one for the whole dashboard.
- Check SSR output matches the initial client state to avoid hydration flips.
Common Mistakes to Avoid
- Writing size-derived styles in
$effect. It runs late and invites feedback loops. - Using unquantised floats as state. Every sub-pixel change becomes a reactive update.
- Binding
clientWidthon dozens of elements. Each binding observes separately; a shared observer is cheaper to reason about. - Deriving height from width in JavaScript.
aspect-ratiodoes it in layout, without a loop.
FAQ
Does bind:clientWidth use ResizeObserver in Svelte 5?
Current versions implement dimension bindings with ResizeObserver rather than the older iframe technique. They are convenient for reading size; the problems described here come from writing size back based on them.
Why does $effect make size loops worse?
Because it runs after the DOM update, in a microtask, not inside the rendering steps. The observer reports, state changes, the effect writes a new size, and the observer reports again in a later frame — visible as jitter rather than a single settled layout.
What step size should I quantise to?
Small enough that decisions are accurate at breakpoints and drawings stay sharp — 4 to 8 CSS pixels is typical. For canvas drawing resolution, quantise after multiplying by devicePixelRatio, or not at all.
Can I put $state in a module file?
Yes, in files named with the .svelte.ts or .svelte.js extension, which Svelte compiles with runes enabled. That is what lets the shared size store above live outside a component.
How is this different in Svelte 4?
Replace $state with a writable store and $derived with a derived store. The action and shared observer are identical, and the same rule applies: derive decisions, do not write size back from a reactive statement.
Related
- Writing a Svelte IntersectionObserver Action — the visibility counterpart
- Fixing ResizeObserver Loop Limit Exceeded — the error these patterns avoid
- Detecting Container Queries with ResizeObserver — when CSS alone is enough