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:

  1. Define a container with container-type: inline-size and container-name: card.
  2. Apply @container card (min-width: 400px) rules that alter padding, font-size, or layout structure.
  3. Attempt to listen for breakpoint changes using a raw ResizeObserver without threshold or breakpoint mapping.
  4. Observe that the callback fires on every fractional pixel change, causing layout thrashing and missing the exact CSS breakpoint transition.
  5. Note that ResizeObserver entries 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-type in the Styles pane and inspect computed width/height to verify the container context is correctly established.
  • Performance Panel: Record a resize interaction. Look for Layout spikes and Recalculate Style events. Filter by ResizeObserver to isolate callback overhead and identify forced synchronous layouts.
  • Console: Log entry.contentRect.width against known breakpoints (e.g., 400px, 600px). Use performance.now() to measure callback execution time and ensure it stays below 16ms for 60fps targets.
  • Rendering Tab: Enable Layout Shift Regions and Container 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: ResizeObserver callbacks are batched by the browser before paint. To maintain 60fps, avoid heavy DOM writes inside the callback. If visual updates are required, defer them using requestAnimationFrame. 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 nullify this.el, this.callback, and internal caches. In SPAs, tie destroy() to framework unmount hooks (useEffect cleanup, onUnmounted, ngOnDestroy).
  • Hydration & SSR: During server-side rendering, ResizeObserver is unavailable. Initialize the detector only after hydration completes (useLayoutEffect or mounted lifecycle). Provide a fallback static breakpoint or null state to prevent hydration mismatches.

Minimal Focused Implementation

JavaScript
/**
 * 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 ResizeObserver targets the exact container-type ancestor. 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 use getBoundingClientRect() on a surrogate element.
  • Iframe Boundaries: Cross-origin iframes block ResizeObserver due to security policies. Use postMessage for cross-frame communication, or fall back to IntersectionObserver for visibility-based triggers.
  • Throttling vs. Debouncing: ResizeObserver already batches updates before paint. Avoid manual setTimeout throttling unless you're performing heavy non-visual computations. For visual sync, prefer requestAnimationFrame.
  • 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.