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.
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.
// 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.
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.widthis 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:
.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 torequestAnimationFrame. - Memory: Take a JS heap snapshot before and after mounting then unmounting the component. The
ContainerQueryBridgeinstance, 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.containerTierin the Elements panel before any resize event fires. It should already reflect the correct tier becauseevaluate()runs during construction. - SSR hydration: In a Next.js or Nuxt app, view source and confirm that no
containerTierdata attribute is set server-side (the observer cannot run on the server). The attribute should appear only after hydration.
Common Mistakes to Avoid
- Calling
getComputedStyleinside the callback. This forces a synchronous layout recalculation on every callback invocation, which is exactly the layout thrashing pattern ResizeObserver is designed to prevent. Useentry.contentRect.widthinstead. - Forgetting
destroy()on route changes. In single-page applications, navigating away from a component without callingdisconnect()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.
@containerqueries apply to the element marked withcontainer-type, not to children inside it. Observe the container element itself. If you observe a deeply nested child,contentRect.widthwill reflect the child's dimensions, not the container boundary the CSS rules are evaluated against. - Using
setTimeoutto debounce.ResizeObserveralready batches callbacks before the next paint frame. Adding a manualsetTimeoutintroduces 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 insideevaluate()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.
Related
- Element Resize Detection Patterns — sibling patterns for hierarchical delegation and component-level observation
- Reducing Layout Thrashing with ResizeObserver — read/write batching and rAF scheduling to keep callbacks off the main thread
- Preventing Memory Leaks in Long-Running Observers —
disconnect()discipline and WeakMap patterns for SPA teardown - Syncing Observer Callbacks with requestAnimationFrame — deferring heavy visual updates out of the observer callback
↑ Back to Element Resize Detection Patterns