Detecting container queries with ResizeObserver
Why Container Queries Lack Native JS Events
CSS Container Queries decouple responsive logic from the viewport, but unlike window.matchMedia, they expose no native JavaScript API for state detection. When building dynamic dashboards or component-driven architectures, engineers often need to synchronize imperative JS logic with @container breakpoints. This guide bridges that gap using ResizeObserver, aligning with established Element Resize Detection Patterns for reliable, performant DOM tracking.
Reproduction Steps & Symptom Identification
To isolate the detection gap, follow this deterministic reproduction workflow:
- Define a container with
container-type: inline-sizeandcontainer-name: card. - Apply
@container card (min-width: 400px)rules that alter padding, font-size, or layout structure. - Attempt to listen for breakpoint changes using a raw
ResizeObserverwithout threshold or breakpoint mapping. - Observe that the callback fires on every fractional pixel change, causing layout thrashing and missing the exact CSS breakpoint transition.
- Note that
ResizeObserverentries trigger continuously during resize, but do not inherently expose which CSS media/container rule is currently active.
Root Cause Analysis: Declarative CSS vs. Imperative DOM
Container queries are evaluated by the browser's layout engine during the style resolution phase. They do not dispatch events because they are declarative constraints, not imperative triggers. Relying on raw ResizeObserver entries without breakpoint mapping leads to redundant computations and unnecessary re-renders. Furthermore, polling getComputedStyle() on every resize frame blocks the main thread and forces synchronous layout recalculations. Proper detection requires mapping observed dimensions to known CSS breakpoints, as documented in broader Implementation Patterns for Viewport & Resize Tracking.
DevTools Workflow for Container Query Debugging
Before implementing programmatic detection, validate the container context and isolate performance bottlenecks using browser DevTools:
- Elements Panel: Toggle
container-typein the Styles pane and inspect computedwidth/heightto verify the container context is correctly established. - Performance Panel: Record a resize interaction. Look for
Layoutspikes andRecalculate Styleevents. Filter byResizeObserverto isolate callback overhead and identify forced synchronous layouts. - Console: Log
entry.contentRect.widthagainst known breakpoints (e.g.,400px,600px). Useperformance.now()to measure callback execution time and ensure it stays below16msfor 60fps targets. - Rendering Tab: Enable
Layout Shift RegionsandContainer Query Layers(Chrome/Edge) to visualize layout recalculations and verify that container boundaries are correctly scoped.
Production-Ready Detection Pattern with Cleanup
The following implementation maps ResizeObserver dimensions to CSS breakpoints, avoids getComputedStyle polling, and guarantees cleanup on component unmount to prevent memory leaks. It uses direct contentRect measurements for zero-overhead evaluation.
Timing, Memory & Hydration Constraints
- Timing:
ResizeObservercallbacks are batched by the browser before paint. To maintain 60fps, avoid heavy DOM writes inside the callback. If visual updates are required, defer them usingrequestAnimationFrame. State changes should be debounced or threshold-gated to prevent redundant framework re-renders. - Memory: Detached DOM nodes retain observer references if
disconnect()is omitted. Always nullifythis.el,this.callback, and internal caches. In SPAs, tiedestroy()to framework unmount hooks (useEffectcleanup,onUnmounted,ngOnDestroy). - Hydration & SSR: During server-side rendering,
ResizeObserveris unavailable. Initialize the detector only after hydration completes (useLayoutEffectormountedlifecycle). Provide a fallback static breakpoint ornullstate to prevent hydration mismatches.
Minimal Focused Implementation
/**
* Production-safe container query breakpoint detector.
* Maps ResizeObserver dimensions to CSS breakpoints without getComputedStyle polling.
*/
class ContainerQueryDetector {
/**
* @param {HTMLElement} element - The container element to observe
* @param {number[]} breakpoints - Array of breakpoint widths in px (e.g., [400, 600])
* @param {function} callback - (activeBreakpoint: number | null, currentWidth: number) => void
*/
constructor(element, breakpoints, callback) {
if (!(element instanceof HTMLElement)) {
throw new TypeError('ContainerQueryDetector requires a valid HTMLElement.');
}
this.el = element;
// Sort ascending to ensure deterministic breakpoint resolution
this.breakpoints = [...breakpoints].sort((a, b) => a - b);
this.callback = callback;
this.currentBreakpoint = null;
this.observer = new ResizeObserver(this._onResize.bind(this));
this._init();
}
_init() {
this.observer.observe(this.el);
// Evaluate initial state synchronously to prevent hydration/layout mismatch
this._evaluate(this.el.getBoundingClientRect().width);
}
_onResize(entries) {
// Process only the target element; batched entries may include others if reused
for (const entry of entries) {
if (entry.target === this.el) {
// Use contentRect for sub-pixel accuracy without triggering layout
this._evaluate(entry.contentRect.width);
}
}
}
_evaluate(width) {
let active = null;
// Find highest breakpoint the current width satisfies
for (const bp of this.breakpoints) {
if (width >= bp) active = bp;
}
// Only invoke callback on actual state transition
if (active !== this.currentBreakpoint) {
this.currentBreakpoint = active;
this.callback(active, width);
}
}
/**
* Deterministic cleanup. Must be called during component unmount.
*/
destroy() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
// Nullify references to prevent detached DOM retention and memory leaks
this.el = null;
this.callback = null;
this.breakpoints = [];
}
}
// Usage Example (Framework Agnostic)
const container = document.querySelector('.card-container');
const detector = new ContainerQueryDetector(
container,
[400, 600, 800],
(activeBp, width) => {
console.log(`Breakpoint transitioned to: ${activeBp}px (current width: ${width}px)`);
// Trigger imperative logic (e.g., chart resize, virtual list config, ARIA updates)
}
);
// Cleanup (e.g., React useEffect return, Vue onUnmounted)
// detector.destroy();
Edge Cases & Performance Safeguards
- Nested Containers: Ensure
ResizeObservertargets the exactcontainer-typeancestor. Observing a deeply nested child may report dimensions that don't align with the CSS container context. display: contents: Elements with this value have zero layout box dimensions. Observe the parent wrapper instead, or usegetBoundingClientRect()on a surrogate element.- Iframe Boundaries: Cross-origin iframes block
ResizeObserverdue to security policies. UsepostMessagefor cross-frame communication, or fall back toIntersectionObserverfor visibility-based triggers. - Throttling vs. Debouncing:
ResizeObserveralready batches updates before paint. Avoid manualsetTimeoutthrottling unless you're performing heavy non-visual computations. For visual sync, preferrequestAnimationFrame. - Memory Leaks: Always call
disconnect()and nullify references in framework lifecycles. Failing to detach observers on route changes or component swaps causes detached DOM retention and gradual memory bloat. - Accessibility Implications: When breakpoint transitions alter content density or hide elements, ensure ARIA states (
aria-expanded,aria-hidden) are updated synchronously with the JS callback to prevent screen reader desynchronization.