JavaScript APIs

JavaScript Resize Observer API: Responsive Element Monitoring

Master the Resize Observer API to efficiently detect element size changes. Learn responsive design patterns, performance optimization, and practical implementations.

By JavaScript Document Team
resize-observerweb-apisresponsiveperformancelayout

The Resize Observer API provides a performant way to monitor changes to an element's size. Unlike window resize events, it can observe individual elements and fires only when an element's dimensions actually change.

Understanding Resize Observer

Resize Observer allows you to watch for changes in an element's content rectangle or border box, making it perfect for responsive components and dynamic layouts.

Basic Resize Observer Setup

// Create a ResizeObserver instance
const resizeObserver = new ResizeObserver((entries, observer) => {
  entries.forEach((entry) => {
    console.log('Element:', entry.target);
    console.log('Content Rectangle:', entry.contentRect);
    console.log('Width:', entry.contentRect.width);
    console.log('Height:', entry.contentRect.height);

    // New properties (if supported)
    if (entry.borderBoxSize) {
      console.log('Border Box Size:', entry.borderBoxSize);
    }
    if (entry.contentBoxSize) {
      console.log('Content Box Size:', entry.contentBoxSize);
    }
    if (entry.devicePixelContentBoxSize) {
      console.log(
        'Device Pixel Content Box Size:',
        entry.devicePixelContentBoxSize
      );
    }
  });
});

// Observe an element
const targetElement = document.querySelector('.resize-me');
resizeObserver.observe(targetElement);

// Stop observing
resizeObserver.unobserve(targetElement);

// Disconnect observer completely
resizeObserver.disconnect();

// Detailed entry information
const detailedObserver = new ResizeObserver((entries) => {
  entries.forEach((entry) => {
    // Legacy contentRect (always available)
    const rect = entry.contentRect;
    console.log({
      x: rect.x,
      y: rect.y,
      width: rect.width,
      height: rect.height,
      top: rect.top,
      right: rect.right,
      bottom: rect.bottom,
      left: rect.left,
    });

    // Modern size arrays (check for support)
    if (entry.borderBoxSize?.length) {
      const borderBox = entry.borderBoxSize[0];
      console.log('Border box:', {
        inlineSize: borderBox.inlineSize,
        blockSize: borderBox.blockSize,
      });
    }

    if (entry.contentBoxSize?.length) {
      const contentBox = entry.contentBoxSize[0];
      console.log('Content box:', {
        inlineSize: contentBox.inlineSize,
        blockSize: contentBox.blockSize,
      });
    }
  });
});

Advanced Configuration

// Resize Observer with options (future support)
const observer = new ResizeObserver(
  (entries) => {
    entries.forEach((entry) => {
      handleResize(entry);
    });
  },
  {
    // Future option: box model to observe
    box: 'content-box', // or 'border-box', 'device-pixel-content-box'
  }
);

// Handling different writing modes
function getElementSize(entry) {
  // Use modern API if available
  if (entry.contentBoxSize?.length) {
    const contentBox = entry.contentBoxSize[0];
    return {
      width: contentBox.inlineSize,
      height: contentBox.blockSize,
    };
  }

  // Fallback to contentRect
  return {
    width: entry.contentRect.width,
    height: entry.contentRect.height,
  };
}

// Device pixel ratio handling
function getDevicePixelSize(entry) {
  if (entry.devicePixelContentBoxSize?.length) {
    const deviceBox = entry.devicePixelContentBoxSize[0];
    return {
      width: deviceBox.inlineSize,
      height: deviceBox.blockSize,
    };
  }

  // Fallback calculation
  const dpr = window.devicePixelRatio || 1;
  return {
    width: entry.contentRect.width * dpr,
    height: entry.contentRect.height * dpr,
  };
}

Practical Applications

Responsive Components

// Responsive card component
class ResponsiveCard {
  constructor(element) {
    this.element = element;
    this.breakpoints = {
      small: 300,
      medium: 500,
      large: 700,
    };

    this.resizeObserver = new ResizeObserver((entries) => {
      const entry = entries[0];
      this.updateLayout(entry.contentRect.width);
    });

    this.resizeObserver.observe(this.element);
  }

  updateLayout(width) {
    // Remove all size classes
    Object.keys(this.breakpoints).forEach((size) => {
      this.element.classList.remove(`card--${size}`);
    });

    // Add appropriate size class
    if (width < this.breakpoints.small) {
      this.element.classList.add('card--small');
    } else if (width < this.breakpoints.medium) {
      this.element.classList.add('card--medium');
    } else {
      this.element.classList.add('card--large');
    }

    // Update internal layout
    this.adjustContent(width);
  }

  adjustContent(width) {
    const title = this.element.querySelector('.card__title');
    const description = this.element.querySelector('.card__description');

    if (width < this.breakpoints.small) {
      // Truncate text for small sizes
      this.truncateText(title, 30);
      this.truncateText(description, 50);
    } else {
      // Restore full text
      this.restoreText(title);
      this.restoreText(description);
    }
  }

  truncateText(element, maxLength) {
    if (!element) return;

    const fullText =
      element.getAttribute('data-full-text') || element.textContent;
    element.setAttribute('data-full-text', fullText);

    if (fullText.length > maxLength) {
      element.textContent = fullText.substring(0, maxLength) + '...';
    }
  }

  restoreText(element) {
    if (!element) return;

    const fullText = element.getAttribute('data-full-text');
    if (fullText) {
      element.textContent = fullText;
    }
  }

  destroy() {
    this.resizeObserver.disconnect();
  }
}

// Container query polyfill using ResizeObserver
class ContainerQuery {
  constructor(element, queries) {
    this.element = element;
    this.queries = queries;
    this.currentQuery = null;

    this.resizeObserver = new ResizeObserver((entries) => {
      const entry = entries[0];
      this.evaluateQueries(entry.contentRect);
    });

    this.resizeObserver.observe(this.element);

    // Initial evaluation
    const rect = this.element.getBoundingClientRect();
    this.evaluateQueries(rect);
  }

  evaluateQueries(rect) {
    const width = rect.width;
    const height = rect.height;

    // Find matching query
    let matchedQuery = null;
    for (const query of this.queries) {
      if (this.matchesQuery(query, width, height)) {
        matchedQuery = query;
      }
    }

    // Apply styles if query changed
    if (matchedQuery !== this.currentQuery) {
      this.applyQuery(matchedQuery);
      this.currentQuery = matchedQuery;
    }
  }

  matchesQuery(query, width, height) {
    let matches = true;

    if (query.minWidth !== undefined && width < query.minWidth) {
      matches = false;
    }
    if (query.maxWidth !== undefined && width > query.maxWidth) {
      matches = false;
    }
    if (query.minHeight !== undefined && height < query.minHeight) {
      matches = false;
    }
    if (query.maxHeight !== undefined && height > query.maxHeight) {
      matches = false;
    }

    return matches;
  }

  applyQuery(query) {
    // Remove all query classes
    this.queries.forEach((q) => {
      if (q.className) {
        this.element.classList.remove(q.className);
      }
    });

    // Apply new query
    if (query) {
      if (query.className) {
        this.element.classList.add(query.className);
      }
      if (query.styles) {
        Object.assign(this.element.style, query.styles);
      }
      if (query.callback) {
        query.callback(this.element);
      }
    }
  }

  destroy() {
    this.resizeObserver.disconnect();
  }
}

// Usage
const containerQuery = new ContainerQuery(element, [
  {
    minWidth: 0,
    maxWidth: 399,
    className: 'container--small',
    styles: { fontSize: '14px' },
  },
  {
    minWidth: 400,
    maxWidth: 799,
    className: 'container--medium',
    styles: { fontSize: '16px' },
  },
  {
    minWidth: 800,
    className: 'container--large',
    styles: { fontSize: '18px' },
    callback: (el) => console.log('Large container activated'),
  },
]);

Dynamic Layouts

// Auto-sizing textarea
class AutoSizeTextarea {
  constructor(textarea) {
    this.textarea = textarea;
    this.minHeight = parseInt(getComputedStyle(textarea).minHeight) || 50;
    this.maxHeight = parseInt(getComputedStyle(textarea).maxHeight) || 300;

    // Create hidden clone for measuring
    this.clone = this.createClone();

    // Set up resize observer on clone
    this.resizeObserver = new ResizeObserver((entries) => {
      const entry = entries[0];
      this.updateHeight(entry.contentRect.height);
    });

    // Observe the clone
    this.resizeObserver.observe(this.clone);

    // Listen for input
    this.textarea.addEventListener('input', () => this.adjustSize());

    // Initial adjustment
    this.adjustSize();
  }

  createClone() {
    const clone = document.createElement('div');
    clone.className = 'textarea-clone';
    clone.style.cssText = `
      position: absolute;
      top: -9999px;
      left: -9999px;
      visibility: hidden;
      white-space: pre-wrap;
      word-wrap: break-word;
    `;

    // Copy styles
    const styles = getComputedStyle(this.textarea);
    [
      'fontFamily',
      'fontSize',
      'fontWeight',
      'lineHeight',
      'padding',
      'border',
      'width',
    ].forEach((prop) => {
      clone.style[prop] = styles[prop];
    });

    document.body.appendChild(clone);
    return clone;
  }

  adjustSize() {
    // Update clone content
    this.clone.textContent = this.textarea.value || 'x'; // 'x' ensures minimum height

    // The resize observer will handle the height update
  }

  updateHeight(height) {
    const newHeight = Math.min(
      Math.max(height, this.minHeight),
      this.maxHeight
    );
    this.textarea.style.height = `${newHeight}px`;
  }

  destroy() {
    this.resizeObserver.disconnect();
    this.clone.remove();
  }
}

// Grid layout manager
class GridLayoutManager {
  constructor(container, options = {}) {
    this.container = container;
    this.options = {
      minItemWidth: 250,
      gap: 16,
      ...options,
    };

    this.resizeObserver = new ResizeObserver((entries) => {
      const entry = entries[0];
      this.updateLayout(entry.contentRect.width);
    });

    this.resizeObserver.observe(this.container);
  }

  updateLayout(containerWidth) {
    const availableWidth = containerWidth - this.options.gap;
    const minWidth = this.options.minItemWidth;

    // Calculate optimal number of columns
    let columns = Math.floor(availableWidth / (minWidth + this.options.gap));
    columns = Math.max(1, columns);

    // Calculate actual item width
    const itemWidth =
      (availableWidth - (columns - 1) * this.options.gap) / columns;

    // Update CSS custom properties
    this.container.style.setProperty('--grid-columns', columns);
    this.container.style.setProperty('--item-width', `${itemWidth}px`);
    this.container.style.setProperty('--gap', `${this.options.gap}px`);

    // Apply grid styles
    this.container.style.display = 'grid';
    this.container.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
    this.container.style.gap = `${this.options.gap}px`;

    // Notify about layout change
    this.container.dispatchEvent(
      new CustomEvent('layoutchange', {
        detail: { columns, itemWidth },
      })
    );
  }

  destroy() {
    this.resizeObserver.disconnect();
  }
}

// Masonry layout with ResizeObserver
class MasonryLayout {
  constructor(container, options = {}) {
    this.container = container;
    this.options = {
      columnWidth: 300,
      gap: 16,
      ...options,
    };

    this.items = [];
    this.columns = [];
    this.resizeObserver = null;
    this.itemObserver = null;

    this.init();
  }

  init() {
    // Observe container
    this.resizeObserver = new ResizeObserver((entries) => {
      const entry = entries[0];
      this.onContainerResize(entry.contentRect.width);
    });

    this.resizeObserver.observe(this.container);

    // Observe individual items
    this.itemObserver = new ResizeObserver((entries) => {
      entries.forEach((entry) => {
        this.onItemResize(entry.target);
      });
    });

    // Initial setup
    this.setupItems();
  }

  setupItems() {
    this.items = Array.from(this.container.children);
    this.items.forEach((item) => {
      item.style.position = 'absolute';
      item.style.width = `${this.options.columnWidth}px`;
      this.itemObserver.observe(item);
    });
  }

  onContainerResize(width) {
    const columnCount = Math.floor(
      width / (this.options.columnWidth + this.options.gap)
    );

    if (columnCount !== this.columns.length) {
      this.setupColumns(columnCount);
      this.layoutItems();
    }
  }

  onItemResize(item) {
    // Relayout when item size changes
    this.layoutItems();
  }

  setupColumns(count) {
    this.columns = Array(count).fill(0);
  }

  layoutItems() {
    this.columns.fill(0);

    this.items.forEach((item) => {
      // Find shortest column
      const shortestColumn = this.columns.indexOf(Math.min(...this.columns));

      // Position item
      const x = shortestColumn * (this.options.columnWidth + this.options.gap);
      const y = this.columns[shortestColumn];

      item.style.transform = `translate(${x}px, ${y}px)`;

      // Update column height
      const itemHeight = item.getBoundingClientRect().height;
      this.columns[shortestColumn] += itemHeight + this.options.gap;
    });

    // Update container height
    const maxHeight = Math.max(...this.columns);
    this.container.style.height = `${maxHeight}px`;
  }

  addItem(element) {
    this.items.push(element);
    element.style.position = 'absolute';
    element.style.width = `${this.options.columnWidth}px`;
    this.itemObserver.observe(element);
    this.layoutItems();
  }

  removeItem(element) {
    const index = this.items.indexOf(element);
    if (index > -1) {
      this.items.splice(index, 1);
      this.itemObserver.unobserve(element);
      this.layoutItems();
    }
  }

  destroy() {
    this.resizeObserver.disconnect();
    this.itemObserver.disconnect();
  }
}

Chart and Visualization Resizing

// Responsive chart component
class ResponsiveChart {
  constructor(container, data, options = {}) {
    this.container = container;
    this.data = data;
    this.options = options;
    this.chart = null;
    this.resizeTimeout = null;

    this.canvas = document.createElement('canvas');
    this.container.appendChild(this.canvas);

    this.resizeObserver = new ResizeObserver((entries) => {
      this.handleResize(entries[0]);
    });

    this.resizeObserver.observe(this.container);

    // Initial render
    this.render();
  }

  handleResize(entry) {
    // Debounce resize events
    clearTimeout(this.resizeTimeout);

    this.resizeTimeout = setTimeout(() => {
      const { width, height } = entry.contentRect;
      this.updateDimensions(width, height);
      this.render();
    }, 100);
  }

  updateDimensions(width, height) {
    // Update canvas size
    this.canvas.width = width * window.devicePixelRatio;
    this.canvas.height = height * window.devicePixelRatio;
    this.canvas.style.width = `${width}px`;
    this.canvas.style.height = `${height}px`;

    // Scale for device pixel ratio
    const ctx = this.canvas.getContext('2d');
    ctx.scale(window.devicePixelRatio, window.devicePixelRatio);

    // Update chart dimensions
    if (this.chart) {
      this.chart.resize(width, height);
    }
  }

  render() {
    const ctx = this.canvas.getContext('2d');
    const width = this.canvas.width / window.devicePixelRatio;
    const height = this.canvas.height / window.devicePixelRatio;

    // Clear canvas
    ctx.clearRect(0, 0, width, height);

    // Simple bar chart example
    const barWidth = width / this.data.length;
    const maxValue = Math.max(...this.data.map((d) => d.value));

    this.data.forEach((item, index) => {
      const barHeight = (item.value / maxValue) * height * 0.8;
      const x = index * barWidth + barWidth * 0.1;
      const y = height - barHeight;

      ctx.fillStyle = item.color || '#007bff';
      ctx.fillRect(x, y, barWidth * 0.8, barHeight);

      // Add label if space permits
      if (barWidth > 50) {
        ctx.fillStyle = '#333';
        ctx.font = '12px Arial';
        ctx.textAlign = 'center';
        ctx.fillText(item.label, x + barWidth * 0.4, height - 5);
      }
    });
  }

  updateData(newData) {
    this.data = newData;
    this.render();
  }

  destroy() {
    clearTimeout(this.resizeTimeout);
    this.resizeObserver.disconnect();
    this.canvas.remove();
  }
}

// SVG responsive graphics
class ResponsiveSVG {
  constructor(container, svgContent) {
    this.container = container;
    this.svg = this.createSVG(svgContent);
    this.container.appendChild(this.svg);

    this.resizeObserver = new ResizeObserver((entries) => {
      const entry = entries[0];
      this.updateViewBox(entry.contentRect);
    });

    this.resizeObserver.observe(this.container);
  }

  createSVG(content) {
    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    svg.innerHTML = content;
    svg.style.width = '100%';
    svg.style.height = '100%';
    return svg;
  }

  updateViewBox(rect) {
    const aspectRatio = rect.width / rect.height;
    const originalAspectRatio = 16 / 9; // Example original aspect ratio

    let viewBox;
    if (aspectRatio > originalAspectRatio) {
      // Container is wider - fit height
      const width = rect.height * originalAspectRatio;
      const x = (rect.width - width) / 2;
      viewBox = `${-x} 0 ${rect.width} ${rect.height}`;
    } else {
      // Container is taller - fit width
      const height = rect.width / originalAspectRatio;
      const y = (rect.height - height) / 2;
      viewBox = `0 ${-y} ${rect.width} ${rect.height}`;
    }

    this.svg.setAttribute('viewBox', viewBox);
  }

  destroy() {
    this.resizeObserver.disconnect();
  }
}

Performance Monitoring

// Element size performance monitor
class SizePerformanceMonitor {
  constructor() {
    this.metrics = new Map();
    this.observer = new ResizeObserver((entries) => {
      entries.forEach((entry) => {
        this.recordMetric(entry);
      });
    });
  }

  monitor(element, name) {
    if (!this.metrics.has(element)) {
      this.metrics.set(element, {
        name,
        resizeCount: 0,
        sizes: [],
        lastResize: null,
        totalResizeTime: 0,
      });
    }

    this.observer.observe(element);
  }

  recordMetric(entry) {
    const metric = this.metrics.get(entry.target);
    if (!metric) return;

    const now = performance.now();
    const size = {
      width: entry.contentRect.width,
      height: entry.contentRect.height,
      timestamp: now,
    };

    // Update metrics
    metric.resizeCount++;
    metric.sizes.push(size);

    if (metric.lastResize) {
      metric.totalResizeTime += now - metric.lastResize;
    }

    metric.lastResize = now;

    // Keep only last 100 sizes
    if (metric.sizes.length > 100) {
      metric.sizes.shift();
    }

    // Check for rapid resizing
    if (metric.resizeCount > 10 && metric.totalResizeTime < 1000) {
      console.warn(`Rapid resizing detected for ${metric.name}`);
    }
  }

  getReport(element) {
    const metric = this.metrics.get(element);
    if (!metric) return null;

    const sizes = metric.sizes;
    const widths = sizes.map((s) => s.width);
    const heights = sizes.map((s) => s.height);

    return {
      name: metric.name,
      resizeCount: metric.resizeCount,
      averageWidth: widths.reduce((a, b) => a + b, 0) / widths.length,
      averageHeight: heights.reduce((a, b) => a + b, 0) / heights.length,
      minWidth: Math.min(...widths),
      maxWidth: Math.max(...widths),
      minHeight: Math.min(...heights),
      maxHeight: Math.max(...heights),
      resizeFrequency: metric.resizeCount / (metric.totalResizeTime / 1000), // per second
    };
  }

  getAllReports() {
    const reports = [];
    this.metrics.forEach((metric, element) => {
      reports.push(this.getReport(element));
    });
    return reports;
  }

  stopMonitoring(element) {
    this.observer.unobserve(element);
    this.metrics.delete(element);
  }

  destroy() {
    this.observer.disconnect();
    this.metrics.clear();
  }
}

// Layout thrashing detector
class LayoutThrashingDetector {
  constructor(threshold = 10) {
    this.threshold = threshold;
    this.resizeEvents = new Map();
    this.thrashingElements = new Set();

    this.observer = new ResizeObserver((entries) => {
      const now = performance.now();

      entries.forEach((entry) => {
        const element = entry.target;

        if (!this.resizeEvents.has(element)) {
          this.resizeEvents.set(element, []);
        }

        const events = this.resizeEvents.get(element);
        events.push(now);

        // Keep only events from last second
        const oneSecondAgo = now - 1000;
        const recentEvents = events.filter((time) => time > oneSecondAgo);
        this.resizeEvents.set(element, recentEvents);

        // Check for thrashing
        if (recentEvents.length > this.threshold) {
          if (!this.thrashingElements.has(element)) {
            this.thrashingElements.add(element);
            this.onThrashingDetected(element, recentEvents.length);
          }
        } else if (this.thrashingElements.has(element)) {
          this.thrashingElements.delete(element);
          this.onThrashingResolved(element);
        }
      });
    });
  }

  observe(element) {
    this.observer.observe(element);
  }

  onThrashingDetected(element, count) {
    console.warn(`Layout thrashing detected on element:`, element);
    console.warn(`${count} resize events in the last second`);

    element.dispatchEvent(
      new CustomEvent('layoutthrashing', {
        detail: { count },
        bubbles: true,
      })
    );
  }

  onThrashingResolved(element) {
    console.log('Layout thrashing resolved for element:', element);

    element.dispatchEvent(
      new CustomEvent('thrashingresolved', {
        bubbles: true,
      })
    );
  }

  getStats() {
    const stats = [];

    this.resizeEvents.forEach((events, element) => {
      stats.push({
        element,
        recentResizes: events.length,
        isThrashing: this.thrashingElements.has(element),
      });
    });

    return stats;
  }

  destroy() {
    this.observer.disconnect();
    this.resizeEvents.clear();
    this.thrashingElements.clear();
  }
}

Advanced Patterns

Responsive Table

// Responsive table that adapts based on available space
class ResponsiveTable {
  constructor(table) {
    this.table = table;
    this.columns = this.analyzeColumns();
    this.priorityColumns = this.setPriorities();

    this.resizeObserver = new ResizeObserver((entries) => {
      const entry = entries[0];
      this.adaptTable(entry.contentRect.width);
    });

    this.resizeObserver.observe(this.table);
  }

  analyzeColumns() {
    const headers = Array.from(this.table.querySelectorAll('thead th'));
    return headers.map((header, index) => ({
      index,
      header,
      priority: parseInt(header.dataset.priority) || 5,
      minWidth: parseInt(header.dataset.minWidth) || 100,
      text: header.textContent,
      visible: true,
    }));
  }

  setPriorities() {
    return [...this.columns].sort((a, b) => b.priority - a.priority);
  }

  adaptTable(availableWidth) {
    let currentWidth = 0;
    const padding = 20; // Account for cell padding

    // Reset all columns to visible
    this.columns.forEach((col) => {
      col.visible = true;
      this.showColumn(col.index);
    });

    // Calculate which columns fit
    for (const column of this.priorityColumns) {
      const columnWidth = column.minWidth + padding;

      if (currentWidth + columnWidth <= availableWidth) {
        currentWidth += columnWidth;
      } else {
        column.visible = false;
        this.hideColumn(column.index);
      }
    }

    // Update mobile view if needed
    if (currentWidth > availableWidth) {
      this.enableMobileView();
    } else {
      this.disableMobileView();
    }
  }

  hideColumn(index) {
    // Hide header
    const headers = this.table.querySelectorAll('thead th');
    if (headers[index]) {
      headers[index].style.display = 'none';
    }

    // Hide cells
    const rows = this.table.querySelectorAll('tbody tr');
    rows.forEach((row) => {
      const cells = row.querySelectorAll('td');
      if (cells[index]) {
        cells[index].style.display = 'none';
      }
    });
  }

  showColumn(index) {
    // Show header
    const headers = this.table.querySelectorAll('thead th');
    if (headers[index]) {
      headers[index].style.display = '';
    }

    // Show cells
    const rows = this.table.querySelectorAll('tbody tr');
    rows.forEach((row) => {
      const cells = row.querySelectorAll('td');
      if (cells[index]) {
        cells[index].style.display = '';
      }
    });
  }

  enableMobileView() {
    this.table.classList.add('table--mobile');

    // Transform to card layout
    const rows = this.table.querySelectorAll('tbody tr');
    rows.forEach((row) => {
      row.classList.add('table__card');

      const cells = row.querySelectorAll('td');
      cells.forEach((cell, index) => {
        const column = this.columns[index];
        if (column && column.visible) {
          cell.setAttribute('data-label', column.text);
        }
      });
    });
  }

  disableMobileView() {
    this.table.classList.remove('table--mobile');

    const rows = this.table.querySelectorAll('tbody tr');
    rows.forEach((row) => {
      row.classList.remove('table__card');

      const cells = row.querySelectorAll('td');
      cells.forEach((cell) => {
        cell.removeAttribute('data-label');
      });
    });
  }

  destroy() {
    this.resizeObserver.disconnect();
    this.disableMobileView();

    // Show all columns
    this.columns.forEach((col) => this.showColumn(col.index));
  }
}

// Responsive navigation menu
class ResponsiveNav {
  constructor(nav) {
    this.nav = nav;
    this.items = Array.from(nav.querySelectorAll('.nav__item'));
    this.moreButton = this.createMoreButton();
    this.moreMenu = this.createMoreMenu();

    this.resizeObserver = new ResizeObserver((entries) => {
      const entry = entries[0];
      this.updateLayout(entry.contentRect.width);
    });

    this.resizeObserver.observe(this.nav);
  }

  createMoreButton() {
    const button = document.createElement('button');
    button.className = 'nav__more';
    button.innerHTML = '⋯ More';
    button.style.display = 'none';
    button.addEventListener('click', () => this.toggleMoreMenu());

    this.nav.appendChild(button);
    return button;
  }

  createMoreMenu() {
    const menu = document.createElement('div');
    menu.className = 'nav__more-menu';
    menu.style.display = 'none';

    document.body.appendChild(menu);
    return menu;
  }

  updateLayout(availableWidth) {
    let currentWidth = 0;
    const moreButtonWidth = 80;
    const visibleItems = [];
    const hiddenItems = [];

    // Reset all items
    this.items.forEach((item) => {
      item.style.display = '';
      const itemWidth = item.getBoundingClientRect().width;

      if (currentWidth + itemWidth + moreButtonWidth <= availableWidth) {
        currentWidth += itemWidth;
        visibleItems.push(item);
      } else {
        hiddenItems.push(item);
      }
    });

    // Update visibility
    if (hiddenItems.length > 0) {
      this.moreButton.style.display = '';
      this.updateMoreMenu(hiddenItems);

      // Hide overflow items
      hiddenItems.forEach((item) => {
        item.style.display = 'none';
      });
    } else {
      this.moreButton.style.display = 'none';
      this.moreMenu.style.display = 'none';
    }
  }

  updateMoreMenu(items) {
    this.moreMenu.innerHTML = '';

    items.forEach((item) => {
      const clone = item.cloneNode(true);
      clone.style.display = '';
      this.moreMenu.appendChild(clone);
    });
  }

  toggleMoreMenu() {
    const isVisible = this.moreMenu.style.display === 'block';
    this.moreMenu.style.display = isVisible ? 'none' : 'block';

    if (!isVisible) {
      this.positionMoreMenu();
    }
  }

  positionMoreMenu() {
    const buttonRect = this.moreButton.getBoundingClientRect();
    this.moreMenu.style.position = 'absolute';
    this.moreMenu.style.top = `${buttonRect.bottom}px`;
    this.moreMenu.style.right = `${window.innerWidth - buttonRect.right}px`;
  }

  destroy() {
    this.resizeObserver.disconnect();
    this.moreButton.remove();
    this.moreMenu.remove();
  }
}

Best Practices

  1. Debounce resize handlers for performance
class DebouncedResizeObserver {
  constructor(callback, delay = 100) {
    this.callback = callback;
    this.delay = delay;
    this.timeouts = new Map();

    this.observer = new ResizeObserver((entries) => {
      entries.forEach((entry) => {
        if (this.timeouts.has(entry.target)) {
          clearTimeout(this.timeouts.get(entry.target));
        }

        const timeout = setTimeout(() => {
          this.callback([entry]);
          this.timeouts.delete(entry.target);
        }, this.delay);

        this.timeouts.set(entry.target, timeout);
      });
    });
  }

  observe(target) {
    this.observer.observe(target);
  }

  unobserve(target) {
    this.observer.unobserve(target);
    if (this.timeouts.has(target)) {
      clearTimeout(this.timeouts.get(target));
      this.timeouts.delete(target);
    }
  }

  disconnect() {
    this.observer.disconnect();
    this.timeouts.forEach((timeout) => clearTimeout(timeout));
    this.timeouts.clear();
  }
}
  1. Use contentRect for dimensions
// Good - use contentRect
const width = entry.contentRect.width;
const height = entry.contentRect.height;

// Avoid - getBoundingClientRect causes reflow
const rect = entry.target.getBoundingClientRect();
  1. Handle device pixel ratio for canvas
function setupCanvas(canvas, width, height) {
  const dpr = window.devicePixelRatio || 1;

  canvas.width = width * dpr;
  canvas.height = height * dpr;
  canvas.style.width = `${width}px`;
  canvas.style.height = `${height}px`;

  const ctx = canvas.getContext('2d');
  ctx.scale(dpr, dpr);

  return ctx;
}
  1. Clean up observers
class Component {
  constructor() {
    this.resizeObserver = new ResizeObserver(this.handleResize);
  }

  connect() {
    this.resizeObserver.observe(this.element);
  }

  disconnect() {
    this.resizeObserver.disconnect();
  }
}

Conclusion

The Resize Observer API provides an efficient way to monitor element size changes, enabling truly responsive components that adapt to their container rather than just the viewport. It's essential for modern web applications that need dynamic layouts, responsive visualizations, and container-based responsive design. By following best practices and understanding its capabilities, you can create highly adaptive user interfaces that provide optimal experiences across all screen sizes and container dimensions.