ResizeObserver とコンテナクエリ:要素レベルのレスポンシブレイアウト

前端工程(更新: 2026年6月5日)

ビューポートレスポンシブから要素レスポンシブへ

従来のレスポンシブはビューポート幅@media)に基づきますが、コンポーネントは自身のコンテナサイズに適応すべきです:

比較項目 @media ビューポートクエリ Container Query ResizeObserver
応答基準 ビューポート幅 コンテナ幅 要素サイズ
スコープ グローバル コンテナ内 JS コールバック
CSS 駆動
JS 駆動
コンポーネント移植性
性能 ブラウザ最適化 ブラウザ最適化 コールバック頻度に注意

ResizeObserver API 詳解

基本的な使い方

const observer = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const { width, height } = entry.contentRect;
    console.log(`${entry.target.className}: ${width}×${height}`);

    const borderBox = entry.borderBoxSize?.[0];
    console.log(`ボーダー含む: ${borderBox?.inlineSize}×${borderBox?.blockSize}`);
  }
});

observer.observe(document.querySelector('.card'));

監視オプション

observer.observe(element, {
  box: 'content-box'  // デフォルト、コンテンツ領域
  // box: 'border-box'  // padding + border 含む
  // box: 'device-pixel-content-box'  // デバイスピクセルレベル
});

実践:適応型カードレイアウト

class ResponsiveCard {
  private observer: ResizeObserver;

  constructor(private card: HTMLElement) {
    this.observer = new ResizeObserver((entries) => {
      const { width } = entries[0].contentRect;
      this.applyLayout(width);
    });
    this.observer.observe(card);
  }

  private applyLayout(width: number) {
    if (width < 200) {
      this.card.setAttribute('data-layout', 'compact');
    } else if (width < 400) {
      this.card.setAttribute('data-layout', 'normal');
    } else {
      this.card.setAttribute('data-layout', 'expanded');
    }
  }

  destroy() {
    this.observer.disconnect();
  }
}
.card[data-layout="compact"] {
  flex-direction: column;
  font-size: 12px;
}
.card[data-layout="normal"] {
  flex-direction: column;
  font-size: 14px;
}
.card[data-layout="expanded"] {
  flex-direction: row;
  font-size: 16px;
}

実践:レスポンシブチャート

class ResponsiveChart {
  private observer: ResizeObserver;
  private chart: Chart | null = null;

  constructor(private container: HTMLElement) {
    this.observer = new ResizeObserver((entries) => {
      const { width, height } = entries[0].contentRect;
      this.resizeChart(width, height);
    });
    this.observer.observe(container);
  }

  private resizeChart(width: number, height: number) {
    if (this.chart) {
      this.chart.resize({ width, height });
    } else {
      this.chart = new Chart(this.container, {
        type: 'line',
        data: this.getChartData(width),
        options: {
          responsive: false,
          maintainAspectRatio: false,
        }
      });
    }
  }

  private getChartData(width: number) {
    const pointCount = width < 300 ? 5 : width < 600 ? 10 : 20;
    return { labels: generateLabels(pointCount), datasets: [...] };
  }

  destroy() {
    this.observer.disconnect();
    this.chart?.destroy();
  }
}

実践:テキスト切り詰め検出

class TextOverflowDetector {
  private observer: ResizeObserver;

  constructor(
    private element: HTMLElement,
    private onOverflow: (isOverflow: boolean) => void
  ) {
    this.observer = new ResizeObserver(() => {
      const isOverflow = this.checkOverflow();
      this.onOverflow(isOverflow);
    });
    this.observer.observe(element);
  }

  private checkOverflow(): boolean {
    return this.element.scrollWidth > this.element.clientWidth;
  }

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

const detector = new TextOverflowDetector(
  titleElement,
  (overflow) => {
    tooltipElement.style.display = overflow ? 'inline' : 'none';
  }
);

CSS Container Queries

基本構文

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

@container card (min-width: 400px) {
  .card {
    flex-direction: row;
    gap: 16px;
  }
  .card-title {
    font-size: 20px;
  }
}

@container card (min-width: 200px) and (max-width: 399px) {
  .card {
    flex-direction: column;
  }
  .card-title {
    font-size: 16px;
  }
}

@container card (max-width: 199px) {
  .card-title {
    font-size: 14px;
  }
  .card-meta {
    display: none;
  }
}

コンテナクエリ単位

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

.card-title {
  font-size: clamp(14px, 5cqi, 24px);
  padding: 2cqi;
}
単位 意味 フルネーム
cqw コンテナ幅の 1% container-query-width
cqh コンテナ高さの 1% container-query-height
cqi コンテナインラインサイズの 1% container-query-inline
cqb コンテナブロックサイズの 1% container-query-block
cqmin min(cqi, cqb) 小さい方の次元
cqmax max(cqi, cqb) 大きい方の次元

コンテナクエリ vs メディアクエリ

同一コンポーネントの異なる場面

<div class="sidebar">
  <product-card />  <!-- サイドバー:狭いコンテナ → コンパクト -->
</div>

<div class="main-content">
  <product-card />  <!-- メイン:広いコンテナ → 展開 -->
</div>
.product-card-container {
  container-type: inline-size;
}

@container (min-width: 350px) {
  .product-card { flex-direction: row; }
}

@container (max-width: 349px) {
  .product-card { flex-direction: column; }
}

メディアクエリでは不可能:同じコンポーネントが異なる位置で自動適応。

比較まとめ

シナリオ @media @container
グローバルレイアウト(ナビ/フッター) ✅ 適切 ❌ 不要
コンポーネント適応 ❌ 手動ブレークポイント ✅ 自動適応
コンポーネント再利用性 低(ビューポート依存) 高(コンテナ依存)
ネストされたコンポーネント ❌ 親コンテナを感知できない ✅ レベルごとに適応
性能 ビューポート変化で発火 コンテナ変化で発火

ResizeObserver + Container Query の組み合わせ

class SmartResponsive {
  private observer: ResizeObserver;

  constructor(private container: HTMLElement) {
    this.observer = new ResizeObserver((entries) => {
      const { width } = entries[0].contentRect;

      if (width < 300) {
        this.showSummary();
      } else {
        this.showFullData();
      }
    });

    this.observer.observe(container);
  }

  private showSummary() {
    this.container.setAttribute('data-density', 'sparse');
  }

  private showFullData() {
    this.container.setAttribute('data-density', 'dense');
  }

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

性能最適化

デバウンス ResizeObserver

function debouncedResizeObserver(
  callback: (entries: ResizeObserverEntry[]) => void,
  delay = 100
): ResizeObserver {
  let timer: number;
  let latestEntries: ResizeObserverEntry[];

  return new ResizeObserver((entries) => {
    latestEntries = entries;
    clearTimeout(timer);
    timer = window.setTimeout(() => callback(latestEntries), delay);
  });
}

バッチ処理

const observer = new ResizeObserver((entries) => {
  requestAnimationFrame(() => {
    for (const entry of entries) {
      updateLayout(entry.target as HTMLElement, entry.contentRect);
    }
  });
});

ブラウザ対応状況

API Chrome Firefox Safari Edge
ResizeObserver 64+ 69+ 13.1+ 79+
Container Queries 105+ 110+ 16+ 105+
Container Units (cqw/cqi) 105+ 110+ 16+ 105+
container-type 105+ 110+ 16+ 105+
borderBoxSize 84+ 69+ 13.1+ 84+

ベストプラクティス

  1. Container Query を優先:純粋なレイアウト適応は CSS コンテナクエリで、JS 不要
  2. ResizeObserver はロジック処理:データ/振る舞いの変更が必要な場合に JS を使用
  3. 速やかに disconnect:コンポーネントのアンマウント時に観察を切断しリーク防止
  4. ループ回避:コールバック内でサイズ変更すると新コールバックが発火—デバウンス必須
  5. container-type の選択:幅のみ適応なら inline-sizesize より高性能

まとめ

要素レベルのレスポンシブはモダンコンポーネント設計の核心要件です。CSS Container Queries で純 CSS のコンテナ適応を実現し、ResizeObserver で精密な JS サイズ感知を提供、両者の組み合わせで完全な要素レベルレスポンシブソリューションを構築できます。「ビューポートブレークポイントのハードコード」に別れを告げ、コンポーネントがどこに配置されても適応する設計へ。

Flexbox ジェネレータ でコンテナ内レイアウトを設計し、グラデーションジェネレータ でレスポンシブ背景を作成、ボックスシャドウジェネレータ でコンテナ内カードスタイルを調整できます。

ブラウザローカルツールを無料で試す →

#ResizeObserver#响应式#容器查询#元素尺寸#布局