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+ |
ベストプラクティス
- Container Query を優先:純粋なレイアウト適応は CSS コンテナクエリで、JS 不要
- ResizeObserver はロジック処理:データ/振る舞いの変更が必要な場合に JS を使用
- 速やかに disconnect:コンポーネントのアンマウント時に観察を切断しリーク防止
- ループ回避:コールバック内でサイズ変更すると新コールバックが発火—デバウンス必須
- container-type の選択:幅のみ適応なら
inline-size、sizeより高性能
まとめ
要素レベルのレスポンシブはモダンコンポーネント設計の核心要件です。CSS Container Queries で純 CSS のコンテナ適応を実現し、ResizeObserver で精密な JS サイズ感知を提供、両者の組み合わせで完全な要素レベルレスポンシブソリューションを構築できます。「ビューポートブレークポイントのハードコード」に別れを告げ、コンポーネントがどこに配置されても適応する設計へ。
Flexbox ジェネレータ でコンテナ内レイアウトを設計し、グラデーションジェネレータ でレスポンシブ背景を作成、ボックスシャドウジェネレータ でコンテナ内カードスタイルを調整できます。
ブラウザローカルツールを無料で試す →
#ResizeObserver#响应式#容器查询#元素尺寸#布局