ResizeObserver is the correct tool for reacting to CSS container query breakpoints in JavaScript — read the element's contentRect.width inside the callback, map it to your breakpoint tiers, and fire imperative logic only on tier transitions.

Problem / Scenario Context

CSS @container rules let you style components based on their own dimensions rather than the viewport. This decoupling is powerful for design-system components, dashboard widgets, and card grids that live inside variable-width columns. The problem arises when you need JavaScript to react to the same breakpoints — for example, to swap a chart's data density, reconfigure a virtual list, or update ARIA state to reflect a layout change.

Unlike window.matchMedia, which fires a JavaScript event when a media query changes state, container queries are evaluated entirely inside the browser's style engine and expose no corresponding JavaScript API. Engineers building these element resize detection patterns commonly reach for ResizeObserver but make the mistake of running logic on every pixel change rather than only on breakpoint transitions. The result is an observer that calls back hundreds of times during a single drag-resize, driving unnecessary re-renders and layout thrashing.

This page solves the detection problem end-to-end: why no native event exists, how to map dimensions to breakpoints efficiently, and how to tear down correctly to avoid memory leaks.

Mechanics Explanation

The SVG below illustrates the separation between the CSS container query path (left) and the ResizeObserver JavaScript bridge (right). Both read the same element dimensions, but they operate in different phases of the rendering pipeline.

CSS @container vs ResizeObserver rendering pipeline Two parallel paths: the CSS style engine evaluates container queries during Recalculate Style and applies matching rules silently. ResizeObserver delivers element dimensions to JavaScript after Layout completes, where breakpoint mapping and imperative logic can run. CSS Engine Path ResizeObserver Path Element resizes (layout flush) Recalculate Style phase @container rule matched CSS applied — no JS event Layout phase completes ResizeObserver callback queued (before paint, after layout) entry.contentRect.width read → map to breakpoint tier Imperative logic runs (only on tier transition) same resize

The CSS engine evaluates @container rules synchronously during the Recalculate Style phase. It silently applies matching declarations — no event is emitted, no JavaScript hook is called. The ResizeObserver callback, by contrast, is queued by the browser after the layout phase and delivered to JavaScript before the next paint. This timing means that both the CSS rule and the ResizeObserver callback respond to the same dimension change, but through entirely separate channels. Bridging them requires reading entry.contentRect.width and comparing it against the same numeric thresholds your CSS @container rules use.

A common mistake is calling getComputedStyle(element).getPropertyValue('--container-width') or reading offsetWidth inside the callback. Both force a synchronous layout recalculation and negate the asynchronous benefit the observer provides. contentRect.width is already computed and available on the entry object at zero additional layout cost.

Comparison Table

Approach Triggers layout recalc? Fires on every pixel? Provides breakpoint tier? Cleanup required?
getComputedStyle in callback Yes — synchronous Yes No — must parse manually No observer to disconnect
Raw ResizeObserver (no gating) No Yes — hundreds/resize No Yes — disconnect()
ResizeObserver + breakpoint map (this page) No No — tier-gated Yes Yes — disconnect()
CSS @container alone No N/A CSS only, no JS N/A

Minimal Reproducible Example

This snippet isolates the exact gap: an element observed by ResizeObserver fires on every fractional-pixel change, not only on breakpoint transitions.

TypeScript
// Demonstrates the raw firing rate without breakpoint gating
const el = document.querySelector<HTMLElement>('.card')!;

const raw = new ResizeObserver((entries: ResizeObserverEntry[]) => {
  for (const entry of entries) {
    // Fires on EVERY pixel — will log 50-100 times during one drag-resize
    console.log('width:', entry.contentRect.width);
  }
});

raw.observe(el);
// Cleanup: raw.disconnect();

During a typical drag-resize, this produces 50–200 log lines for a single continuous gesture — most of which do not cross a CSS breakpoint boundary.

Production-Safe Solution

The following TypeScript class maps observed widths to an explicit breakpoint list and invokes the callback only on tier transitions, mirroring how observer lifecycle and memory management should be handled in production components.

TypeScript
interface ContainerBreakpointCallback {
  (activeTier: number | null, currentWidth: number): void;
}

/**
 * Maps ResizeObserver dimensions to CSS @container breakpoint tiers.
 * Fires the callback only when the active tier changes, not on every pixel.
 */
class ContainerQueryBridge {
  private el: HTMLElement | null;
  private breakpoints: number[];
  private onTierChange: ContainerBreakpointCallback | null;
  private activeTier: number | null = null;
  private observer: ResizeObserver;

  constructor(
    element: HTMLElement,
    breakpoints: number[],
    callback: ContainerBreakpointCallback
  ) {
    if (!(element instanceof HTMLElement)) {
      throw new TypeError('ContainerQueryBridge requires a valid HTMLElement.');
    }
    this.el = element;
    // Sort ascending so the highest satisfied breakpoint wins
    this.breakpoints = [...breakpoints].sort((a, b) => a - b);
    this.onTierChange = callback;
    this.observer = new ResizeObserver(this.handleResize.bind(this));
    this.observer.observe(this.el);
    // Evaluate immediately so the initial state is correct before first paint
    this.evaluate(this.el.getBoundingClientRect().width);
  }

  private handleResize(entries: ResizeObserverEntry[]): void {
    for (const entry of entries) {
      if (entry.target === this.el) {
        // contentRect.width: no layout recalc, sub-pixel accurate
        this.evaluate(entry.contentRect.width);
      }
    }
  }

  private evaluate(width: number): void {
    let tier: number | null = null;
    for (const bp of this.breakpoints) {
      if (width >= bp) tier = bp;
    }
    // Only invoke callback on an actual tier change
    if (tier !== this.activeTier) {
      this.activeTier = tier;
      this.onTierChange?.(tier, width);
    }
  }

  /** Call during component unmount to prevent detached-DOM memory retention. */
  destroy(): void {
    this.observer.disconnect();
    this.observer = null as unknown as ResizeObserver;
    this.el = null;
    this.onTierChange = null;
    this.breakpoints = [];
  }
}

// --- Usage (framework-agnostic) ---
const card = document.querySelector<HTMLElement>('.card-container')!;
const bridge = new ContainerQueryBridge(
  card,
  [400, 600, 800], // match your @container breakpoints exactly
  (tier, width) => {
    // Apply a data attribute so CSS can piggyback on the JS state if needed
    card.dataset.containerTier = tier !== null ? String(tier) : 'base';
    console.log(`Tier: ${tier}px — current width: ${width}px`);
  }
);

// React: return () => bridge.destroy(); inside useEffect
// Vue:   onUnmounted(() => bridge.destroy());
// Angular: ngOnDestroy() { bridge.destroy(); }

Key details:

  • contentRect.width is read from the batched entry — zero extra layout cost.
  • The sorted breakpoint loop finds the highest tier whose threshold the current width meets.
  • evaluate() short-circuits if the tier has not changed, eliminating redundant framework re-renders.
  • The initial call to evaluate() in the constructor seeds the correct state before the first paint, preventing a hydration mismatch in SSR environments.

Aligning JavaScript breakpoints with CSS

Your breakpoint array must exactly match the numeric thresholds in your @container rules:

CSS
.card-container {
  container-type: inline-size;
  container-name: card;
}

/* 400px tier */
@container card (min-width: 400px) {
  .card__body { font-size: 1rem; }
}

/* 600px tier */
@container card (min-width: 600px) {
  .card__body { column-count: 2; }
}

Pass [400, 600] to ContainerQueryBridge and both the CSS rules and the JavaScript callback will transition at identical widths.

Verification Steps

After integrating ContainerQueryBridge, confirm correct behaviour with these DevTools steps:

  • Console log count: Wrap the callback in a counter. Drag the container edge slowly through a breakpoint. The counter should increment only once per threshold crossing, not continuously. If it logs on every pixel, the tier-gating logic is not running.
  • Performance panel: Record a 5-second resize session. Open the Bottom-Up tab and filter for ResizeObserver. Confirm total callback time is under 2 ms for a typical component. Spikes indicate work inside the callback that should be deferred to requestAnimationFrame.
  • Memory: Take a JS heap snapshot before and after mounting then unmounting the component. The ContainerQueryBridge instance, the observed element, and the internal callback should all be absent from the post-unmount snapshot. If they appear, destroy() was not called.
  • Initial state: Disable JavaScript, resize the browser to a known tier, then re-enable. Reload and check card.dataset.containerTier in the Elements panel before any resize event fires. It should already reflect the correct tier because evaluate() runs during construction.
  • SSR hydration: In a Next.js or Nuxt app, view source and confirm that no containerTier data attribute is set server-side (the observer cannot run on the server). The attribute should appear only after hydration.

Common Mistakes to Avoid

  • Calling getComputedStyle inside the callback. This forces a synchronous layout recalculation on every callback invocation, which is exactly the layout thrashing pattern ResizeObserver is designed to prevent. Use entry.contentRect.width instead.
  • Forgetting destroy() on route changes. In single-page applications, navigating away from a component without calling disconnect() leaves the observer attached to a detached DOM node. The node, the callback closure, and anything the closure references are all leaked. See preventing memory leaks in long-running observers for the full pattern.
  • Observing the wrong element. @container queries apply to the element marked with container-type, not to children inside it. Observe the container element itself. If you observe a deeply nested child, contentRect.width will reflect the child's dimensions, not the container boundary the CSS rules are evaluated against.
  • Using setTimeout to debounce. ResizeObserver already batches callbacks before the next paint frame. Adding a manual setTimeout introduces an unnecessary delay and can cause a visual mismatch between the CSS-applied state (which updates synchronously) and the JavaScript tier (which would update a tick later). Tier-gating inside evaluate() is the correct throttle.

Frequently Asked Questions

Does CSS @container fire a JavaScript event?

No. Container queries are evaluated inside the browser's style resolution phase and are purely declarative. No event is dispatched and there is no matchMedia-equivalent API for container queries as of 2026. ResizeObserver is the correct JavaScript bridge.

Can I use getComputedStyle to read the active breakpoint?

You can, but should not. getComputedStyle forces a synchronous layout recalculation each time it is called inside a resize callback, which defeats the performance benefit of using an observer. Reading entry.contentRect.width from the callback entry is zero-cost because the value is already computed.

How do I stop the callback firing on every fractional pixel?

Track activeTier as a variable and invoke your handler only when the tier changes. The evaluate() method in the implementation above does exactly this with a single strict-equality check before calling the callback.


↑ Back to Element Resize Detection Patterns