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:

  1. ResizeObserver callbacks run inside the rendering steps, after layout, before paint — described in ResizeObserver and the update-the-rendering steps.
  2. 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.
  3. 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.

The Size Feedback LoopFour boxes in a cycle drawn left to right. ResizeObserver reports a new width. The width state updates. An effect writes the chart height from the width. The chart height changes the grid row, which changes the card's width through a scrollbar, and ResizeObserver reports again.RO reports width640 → 623$state updateswidth = 623$effect writeschart height = width ×0.5Layout changesscrollbar toggles;width moves again

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

HTML
<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.

TypeScript
// 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 };
}
HTML
<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.

Driving Writes Versus Deriving DecisionsTwo columns. Driving writes from size state sets the chart height in an effect, changes the observed layout, and can loop. Deriving decisions from size state only computes compact mode and a drawing width, leaves heights to CSS aspect-ratio, and quantises sizes so small fluctuations produce no new state.Size drives writes$effect sets chart height from widthThe write changes the card's layoutRO fires again; state changes againJitter, or the loop-limit errorSize drives decisions$derived computes compact or fullCSS aspect-ratio sets the heightWidth quantised to 8 px stepsNo write-back, no loop

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 width attribute 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.
TypeScript
// 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.

Where Should a Size-Driven Change Go?A decision chain. If CSS can express the change with container queries or aspect-ratio, use CSS. Otherwise, if it is a decision such as compact mode, use a derived value. Otherwise, if it is a write to something not observed, such as a canvas backing store, write it in the observer callback. Otherwise the write affects the observed box and must be idempotent and quantised.Can container queries or aspect-ratio expressit?CSS — no JavaScript at allyesnoIs it a decision, like compact or full?$derived from quantised size stateyesnoIs it a write to something not observed?Write in the observer callback, same frameyesnoIt affects the observed box: make it idempotent, quantised, and test for loops.

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 clientWidth on dozens of elements. Each binding observes separately; a shared observer is cheaper to reason about.
  • Deriving height from width in JavaScript. aspect-ratio does 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.


↑ Back to Svelte Actions & Solid Primitives for Observers