zudo-css-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

コンテナクエリ

ビューポートではなく親コンテナのサイズに応じてコンポーネントのスタイルを切り替える方法。

問題

メディアクエリ(media query)はビューポートの幅に応じて反応しますが、コンポーネントのコンテナの幅には対応しません。コンポーネントがサイドバー、モーダル、あるいは制約のあるレイアウトに配置された場合、ビューポートベースのメディアクエリではコンポーネントのレイアウトを実際の利用可能スペースに適応させることができません。AIエージェントはコンポーネントレベルのレスポンシブ対応にほぼ常に @media クエリを使い、コンテナクエリ(container query)を完全に無視してしまいます。

解決方法

CSSコンテナクエリ(@container)を使うと、コンポーネントをビューポートではなく親コンテナのサイズに応じて変化させることができます。これにより、コンポーネントが異なるレイアウトコンテキストで真に再利用可能になります。コンテナクエリは Baseline 2023 であり、すべてのモダンブラウザでサポートされています。

Safari 16 / iOS 16 が必要

コンテナクエリには Safari 16(iOS 16、2022年9月リリース)以降が必要です。古い Safari は @container に対してエラーやクラッシュを起こすわけではなく、ルールブロック全体を黙って無視します。そのため、内部の要素はクエリされていないベーススタイルしか受け取りません。

プロジェクトの browserslist が古い iOS(例えば ios >= 15.4)をターゲットにしている場合、そうしたクエリされていないベーススタイルは、フォールバックを待つ仮の状態ではなく、その層の訪問者にとって許容できる単独の結果として扱いましょう。Tailwind はコンテナクエリのポリフィルを提供していないため、自動的にフォールバックする仕組みはありません。

コンテナの設定

親要素を container-type を使ってコンテインメントコンテキスト(containment context)として宣言する必要があります。最も一般的な値は inline-size で、コンテナのインライン方向(水平方向)のサイズに基づくクエリを有効にします。

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

コンテナへのクエリ

@container (min-width: 400px) {
  .card {
    display: grid;
    grid-template-columns: 200px 1fr;
  }
}

基本的なコンテナクエリ

このデモでは iframe がコンテナの境界として機能します。ビューポートボタンを使って、カードレイアウトがコンテナの幅に応じて変化する様子を確認しましょう。

基本的なコンテナクエリ — カードレイアウトがコンテナ幅に応じて変化

名前付きコンテナ

コンテナがネストされている場合、@container クエリは container-type が設定されている最も近い祖先要素にマッチします。特定のコンテナをターゲットにするには、container-name を使い、クエリ内でその名前を参照します。

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

.main-content {
  container-type: inline-size;
  container-name: main;
}

/* Only responds to the sidebar container */
@container sidebar (max-width: 300px) {
  .nav-list {
    flex-direction: column;
  }
}

container ショートハンドプロパティで両方を組み合わせることができます:

.sidebar {
  container: sidebar / inline-size;
}
名前付きコンテナ — 同じコンポーネントがサイドバーとメインで異なる表示に

Tailwind CSSでの書き方

Tailwind CSS v4 はコンテナクエリをネイティブでサポートしており、プラグインは不要です。以下の各ユーティリティは、このページですでに紹介したプレーンCSSのパターンにそれぞれ直接対応しています。

コンテナのマーキング

@container クラスは container-type: inline-size に相当するユーティリティです。これは上の「コンテナの設定」セクションでプレーンCSSが行っているのと同じことです。

<div class="@container">
  <div class="flex flex-col @sm:flex-row @lg:gap-8">
    <!-- ... -->
  </div>
</div>

ブロックサイズ(高さ基準)のコンテナが必要な場合、Tailwind には対応する静的ユーティリティがありません。代わりに任意の値を使う @container-[size] を使います。これが container-type: size に相当します。

コンテナクエリのバリアントスケール

@sm:@md:@lg: などはコンテナ幅のバリアントです。これらは、Tailwind の他の場所で使われている sm: / md: / lg: / xl: / 2xl: というビューポート幅のブレークポイントとはまったく別のスケールです。見た目のプレフィックスは似ていますが、対象の軸もピクセル値も異なります。Tailwind のデフォルトのコンテナスケールは @3xs から @7xl まであります。

バリアントコンテナの最小幅
@3xs16rem (256px)
@2xs18rem (288px)
@xs20rem (320px)
@sm24rem (384px)
@md28rem (448px)
@lg32rem (512px)
@xl36rem (576px)
@2xl42rem (672px)
@3xl48rem (768px)
@4xl56rem (896px)
@5xl64rem (1024px)
@6xl72rem (1152px)
@7xl80rem (1280px)

これをビューポートブレークポイントのスケールと比べると、sm は 40rem (640px)、md は 48rem (768px)、lg は 64rem (1024px)、xl は 80rem (1280px)、2xl は 96rem (1536px) です。@md:md: は同じ幅で発動するわけではありません。これらは表記が違うだけの同じスケールではありません。

テーマトークン: --container-*--breakpoint-*

この2つのスケールは、それぞれ独立した2つのテーマ名前空間から生成されています。

  • --breakpoint-*sm: / md: / ... というビューポートバリアントを生成します。

  • --container-*@sm: / @md: / ... というコンテナクエリバリアントを生成します。

通常の @import "tailwindcss"; では、両方の名前空間がデフォルト値で完全に埋まった状態で提供され、両者は互いに影響しません。--breakpoint-* を再定義しても(例えばカスタムの 3xl ビューポートブレークポイントを追加しても)--container-* には何の影響もありませんし、その逆も同様です。

この罠はテーマをリセットするプロジェクトで発生します。Tailwind のデフォルトテーマを消去し(@theme 内で --*: initial;)、必要なトークンだけを再構築するようなセットアップです。--breakpoint-* をカスタムのビューポートスケール用に再定義しても、--container-* を再定義し忘れることは簡単に起こります。

@import "tailwindcss";

@theme {
  --*: initial;
  --breakpoint-sm: 30rem;
  --breakpoint-md: 48rem;
  --breakpoint-lg: 64rem;
  /* --container-* was never redefined */
}

このセットアップでは、@sm:@md:、その他すべての名前付きコンテナバリアントに対応するテーマ値が存在しません。エラーは何も出ませんが、バリアントはルールを一切生成しないため、@sm:flex-row のようなクラスは黙って何もしません。これには2つの回避策があります。

  • 名前付きスケールを使わず、代わりに任意の値を使う: @min-[384px]:flex-row@max-[900px]:gap-2

  • プロジェクトが実際に使う --container-* トークンを定義する。

@theme {
  --container-sm: 24rem;
  --container-md: 28rem;
  --container-lg: 32rem;
}

Tailwindでの名前付きコンテナ

@container/label はコンテインメントコンテキストに名前を付け、@sm/label:(およびスケールの他の値)はそれをターゲットにします。これは上の「名前付きコンテナ」セクションで紹介した container-name@container name (...) パターンに相当するユーティリティです。

<div class="@container/sidebar">
  <nav class="flex flex-col @sm/sidebar:flex-row">
    <!-- ... -->
  </nav>
</div>

プレーンCSSパターンとの対応表

TailwindプレーンCSSこのページの該当セクション
@containercontainer-type: inline-size;「コンテナの設定」
@sm:@md:、...@container (min-width: ...) { }「コンテナへのクエリ」
@container/sidebar + @sm/sidebar:container: sidebar / inline-size; + @container sidebar (min-width: ...)「名前付きコンテナ」

コンテナクエリの単位

コンテナクエリ単位(container query unit)は、クエリコンテナの寸法に対する相対値です。コンポーネント内でのフルイドサイジングに便利です。

  • cqw — コンテナの幅の1%

  • cqh — コンテナの高さの1%

  • cqi — コンテナのインラインサイズの1%

  • cqb — コンテナのブロックサイズの1%

  • cqmincqicqb の小さい方

  • cqmaxcqicqb の大きい方

これらの単位をclamp()と組み合わせた流体フォントサイズのパターン(加算型: rem + cqi と 純粋型: cqiのみ)については、clamp()を使った流体フォントサイズを参照してください。

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

.card__title {
  /* 5% of the container's inline size, clamped */
  font-size: clamp(1rem, 5cqi, 2rem);
}

.card__body {
  /* Padding relative to container width */
  padding: 2cqi;
}
コンテナクエリ単位 — テキストと余白がコンテナに応じてスケール

トークンスケールとしてのコンテナクエリ単位

単発のclamp()は1つの要素にしか対応できません。ステージ、埋め込みウィジェット、カードシステムなど、コンポーネントのサブツリー全体を1つの単位としてスケールさせる必要がある場合は、clamp()の値をカスタムプロパティとして一度だけ定義し、サブツリー全体で使い回します。その結果、ビューポートではなくコンポーネント自身の幅に比例するタイポグラフィとスペーシングのスケールが得られます。

.stage {
  container-type: inline-size;
  container-name: stage;

  /* Type scale — proportional to the stage, not the viewport */
  --font-size-stage-sm: clamp(0.8rem, 0.7rem + 0.9cqi, 1rem);
  --font-size-stage-md: clamp(0.95rem, 0.8rem + 1.4cqi, 1.3rem);
  --font-size-stage-lg: clamp(1.1rem, 0.9rem + 1.8cqi, 1.6rem);

  /* Spacing scale */
  --space-stage-sm: clamp(0.25rem, 0.2rem + 0.5cqi, 0.5rem);
  --space-stage-md: clamp(0.5rem, 0.4rem + 1cqi, 1rem);
  --space-stage-lg: clamp(1rem, 0.8rem + 2cqi, 2rem);
}

.stage__title {
  font-size: var(--font-size-stage-lg);
  margin-block-end: var(--space-stage-sm);
}

このパターンの典型的なホストは固定アスペクト比のステージです。コンテナの設定自体についてはレターボックスステージを参照してください。各clamp()のslopeは、ビューポートベースの流体タイポグラフィと同じ計算式で導出します。clamp()を使った流体フォントサイズを参照してください。スケールのステップ数は少数に抑えましょう。タイトトークン戦略で述べられている規律の議論は、コンポーネントスコープのトークンにもそのまま当てはまります。

このようなスケールを破綻させないためのルールは3つあります。

rem項を優位に保つ

ブラウザのテキスト拡大(テキストのみのズームや、デフォルトフォントサイズの拡大)はrem値をスケールしますが、cqi値はスケールしません。テキストを拡大してもコンテナは広くならないため、cqi項は同じ長さに解決され、rem項だけが大きくなります。cqi項が優位なトークンでは、ユーザーがテキストを拡大しても表示サイズがほとんど変わりません。これは、テキストを200%まで拡大しても機能し続けることを求めるWCAG 1.4.4(テキストのサイズ変更)に違反します。

目安:コンポーネントの設計幅において、rem項が解決後のpreferred値の約60%以上を占めるようにします。設計幅でpreferred式を解決して検証します。意図的な例外がひとつあります。固定コンポジションのステージ(スライド、デッキビューア)は逆に pure-cqi の比例タイプが正解です — ステージ比例タイポグラフィを参照。

/* Design width: stage = 480px */

/* OK: rem-dominant */
--font-size-stage-lg: clamp(1.1rem, 0.9rem + 1.8cqi, 1.6rem);
/* rem term:  0.9rem        = 14.4px  */
/* cqi term:  1.8% of 480px =  8.64px */
/* rem share: 14.4 / 23.04  = 62.5%   */

/* NG: cqi-dominant */
--font-size-stage-lg: clamp(1.1rem, 0.25rem + 4cqi, 2rem);
/* rem term:  0.25rem       =  4px    */
/* cqi term:  4% of 480px   = 19.2px  */
/* rem share: 4 / 23.2      = ~17%    */

このデモは、ルートフォントサイズを2倍にすることで200%のテキストズームをシミュレートしています。これはブラウザのテキスト拡大と同じ仕組みです。ステージの幅は280pxに固定されているため、cqi項はトグルの前後で同じ値に解決されます。2つの見出しはほぼ同じサイズから始まりますが、ズームに反応するのはrem優位のトークンだけです。

200%テキストズーム(シミュレーション)でのrem優位トークンとcqi優位トークン

cqトークンはコンテナのサブツリー内でのみ使う

カスタムプロパティが保持するのはcqiの式であり、解決済みの長さではありません。単位はトークンが使用される場所で、その要素の最も近い祖先コンテナに対して解決されます。祖先コンテナが存在しない場合は、small viewportサイズに対して解決されます。ステージ用のスペーシングトークンをステージ外側のクローム(ツールバー、オーバーレイ、ステータスバーなど)で使うと、ステージ以外の何かに対して解決されることになります。ページを開発した幅では2つの解決結果がたまたま一致することもありますが、極端な幅では外側の使用箇所が異常な余白へと乖離していきます。

ルール:cq単位のトークンは、それが設計されたコンテナのサブツリーにスコープされます。そのサブツリーの外側のクロームには、固定またはremのトークンを使います。疑うべきパターン:

  • ステージスコープの変数(--space-stage-*--font-size-stage-*)が、ツールバー、オーバーレイ、ツールチップなど、ステージのサブツリー外でレンダリングされる要素から参照されている

  • cq単位のトークンがコンテナ要素自体で使われている — コンテナは自分自身のサイズをクエリできないため、単位は1つ上のコンテナに対して解決されます

  • あるページでは正しいスペーシングが、より広いページでは大きく異なる — トークンがビューポートフォールバックに対して解決されている典型的な症状

デモでは、1つの--space-md: 4cqiトークンをステージ外のツールバーとステージ内の行の両方で使っています。ステージは280px固定なので、内側のgapはどのビューポートでも約11pxで安定しています。ツールバーには祖先コンテナがないため、そのgapはiframeのビューポートに対して解決されます。Mobileビューポートでは2つがたまたま一致しますが、Fullビューポートではツールバーのgapが爆発的に広がります。

同じcqiトークンをコンテナの内側と外側で使用 — ビューポートを切り替えて確認

ヘアラインは固定pxを維持する

1pxのヘアラインは、画面がレンダリングできる下限にすでに達しています。これにcqiの幅を与えると、コンテナが狭くなるにつれてサブピクセルになります。ブラウザは丸め処理とディスプレイ密度に応じて、1px未満の線をアンチエイリアスのかかった薄いにじみとして描画するか、完全に消してしまいます。それ以外が完全に流体のスケールであっても、1〜2pxのヘアライン(ボーダー、罫線、コネクタ)はpxのリテラル値のまま維持します。

/* NG: goes sub-pixel below a 400px stage and fades out */
.stage__divider {
  border-block-start: 0.25cqi solid hsl(215, 30%, 75%);
}

/* OK: fluid space, fixed hairline */
.stage__card {
  padding: var(--space-stage-md);
  border: 1px solid hsl(215, 30%, 75%);
}

ステージの右下隅にあるリサイズハンドルをドラッグして狭めながら比較してください。0.25cqiのボーダーと罫線は見えなくなる方向にフェードしていきますが、1pxのものはどの幅でもくっきりと表示されたままです。

cqi流体ヘアラインと固定1pxヘアライン — ステージを狭くドラッグ

3つのルールまとめ

ルール防げる失敗
設計幅でrem項が解決値の約60%以上を占めるブラウザのテキスト拡大を無視するテキスト(WCAG 1.4.4)
cqトークンはコンテナのサブツリー内でのみ使う極端な幅でクロームの余白が異常になる
ヘアラインはpxのリテラル値のまま狭いコンテナでボーダーや罫線がフェードアウトする

コンテナ幅に適応するカードコンポーネント

よくある実用的なユースケースとして、狭いサイドバー、中幅のグリッドカラム、全幅のメインエリアなど、どのレイアウトコンテキストでも機能するカードコンポーネントがあります。

アダプティブカードグリッド — カードがビューポートではなくコンテナに応じて変化

コンテナクエリとメディアクエリの比較

重要な違い:メディアクエリはビューポートに応じて反応し、コンテナクエリは親コンテナに応じて反応します。このデモでは、同じページ上の幅が異なる2つのコンテナに同一コンポーネントを配置しています。メディアクエリ版はビューポートが変わっていないため両方とも同じ見た目になります。コンテナクエリ版はそれぞれのコンテナに独立して適応します。

コンテナクエリ vs メディアクエリ — 同じコンポーネント、異なるコンテナ

上のデモでは、@container カードはそれぞれ独立して適応します。狭いコンテナ内のカードは縦に積み重なり、広いコンテナ内のカードは横並びになります。@media カードはビューポート(iframe)が 300px より広いため両方とも横並びになります。どちらのカードも実際のコンテナの幅を認識していません。

コード例

レスポンシブカードコンポーネント

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

/* Base: stacked layout */
.card {
  display: flex;
  flex-direction: column;
}

.card__image {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

/* When container is wide enough: horizontal layout */
@container card (min-width: 500px) {
  .card {
    flex-direction: row;
  }

  .card__image {
    width: 200px;
    aspect-ratio: 1;
  }
}

/* When container is very wide: add extra spacing */
@container card (min-width: 800px) {
  .card {
    gap: 2rem;
    padding: 2rem;
  }

  .card__image {
    width: 300px;
  }
}

コンテナに適応するナビゲーション

.nav-wrapper {
  container-type: inline-size;
  container-name: nav;
}

.nav-list {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  list-style: none;
  padding: 0;
  margin: 0;
}

/* Horizontal layout when container allows */
@container nav (min-width: 600px) {
  .nav-list {
    flex-direction: row;
    gap: 1rem;
  }
}

コンテナクエリとコンテナクエリ単位の組み合わせ

.widget-wrapper {
  container: widget / inline-size;
}

.widget__title {
  font-size: clamp(1rem, 5cqi, 2rem);
}

.widget__body {
  padding: clamp(0.5rem, 3cqi, 1.5rem);
}

@container widget (min-width: 400px) {
  .widget {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: 1rem;
  }
}

AIがよくやるミス

  • コンポーネントレイアウトにメディアクエリを使う: AIエージェントは、コンポーネントがビューポートではなくコンテナに適応する必要がある場合でも、デフォルトで @media クエリを使ってしまいます。

  • container-type を忘れる: 親要素に container-type を設定せずに @container ルールを書いてしまいます。コンテナは明示的に宣言する必要があります。

  • container-type: size を不必要に使う: 高さベースのコンテインメント(size)はレイアウトの問題を引き起こす可能性があります。ほとんどの場合は inline-size を使いましょう。

  • ネストされたコンテナに名前を付けない: コンテナがネストされているとき、container-name を省略すると曖昧さが生じます。@container クエリは最も近い祖先コンテナにマッチします。ネスト時は特定の祖先をターゲットにするためにコンテナに名前を付けましょう。

  • 要素自体にクエリしてしまう: @container クエリは container-type が設定された最も近い祖先をターゲットにするのであって、スタイルを適用する要素自体ではありません。コンテナとスタイル対象の要素は別の要素である必要があります。

  • 流体トークンでcqi項を優位にしてしまう: ブラウザのテキスト拡大はremをスケールしますがcqiはスケールしないため、cqi優位のclamp()はユーザーがテキストを拡大してもほとんど反応しません。設計幅でrem項が解決値の約60%以上を占めるようにしましょう。

  • cq単位のトークンをコンテナのサブツリー外で使ってしまう: 単位は使用する要素の側で、その要素自身の最も近い祖先コンテナ(なければsmall viewportフォールバック)に対して解決されます。トークンが設計されたコンテナに対してではありません。

  • cqiによる流体ボーダー幅: ヘアラインは狭いコンテナでサブピクセルになりフェードアウトします。1〜2pxのヘアラインはpxのリテラル値のままにしましょう。

使い分け

判断の核となる基準

決め手となる問いは「このコンポーネントは小さいかどうか」ではありません。この要素にとって適切なサイズやレイアウトが、自分自身のボックスの関数なのか、それともビューポートの関数なのか、という点です。同じコンポーネントの2つのインスタンスが、まったく同じビューポート幅で異なるレイアウトを必要とすることがあるなら、メディアクエリはそれを表現できません。メディアクエリが知っているのはビューポートだけだからです。コンポーネント自身のラッパーにスコープされたコンテナクエリなら、それができます。

実例: サイドバーの有無で変わるリードコンポーネント

ホームページの「リード」コンポーネント(サムネイル、タイトル、説明文)はよくあるケースです。理想的なサムネイル幅はビューポートに直接依存するのではなく、サイドバーが同じページにあるかどうかに依存します。まったく同じビューポート幅でも、あるテンプレートではリードのラッパーがページ幅にほぼ等しくなり、別のテンプレートではサイドバーカラムがスペースの一部を占めるため明らかに狭くなります。ビューポートのメディアクエリはこの違いを見ることができません。両方のテンプレートが共有する、たった一つのビューポート幅しか見えないからです。リード自身のラッパーに対するコンテナクエリなら、実際に異なる結果を見ることができます。

下のデモでは、2つのホームページレイアウトが同じビューポートで描画されています。レイアウトAにはサイドバーがなく、レイアウトBにはあります。2つのリードコンポーネントを比較してください。同じビューポート、異なるコンテナ幅、異なるレイアウト——これはすべて @container によるものであり、リードコンポーネント自体に対するメディアクエリは一切ありません。

リードコンポーネント: 同じビューポート、2つの異なるコンテナ幅

レイアウトAのリードはページシェル内に単独で置かれているため、そのコンテナは利用可能な幅にほぼ等しく、横並びレイアウトを発動させるのに十分な広さです。レイアウトBのリードはシェルをサイドバーと共有しているため、ページのビューポートがまったく変わっていないにもかかわらず、そのコンテナは狭くなり、コンパクトな縦積みレイアウトのままになります。どちらのリードコンポーネントもビューポートをクエリしておらず、それぞれが自分自身のラッパーにのみ反応しています。

判断ガイド

  • コンポーネント自身のボックスがレイアウトを決める場合は @container を使いましょう: カード、ナビゲーションバー、フォームグループなど、「一度設計してどこにでも配置する」タイプのコンポーネントで、周囲のページ構造(サイドバー、モーダル、グリッドカラム)がコンポーネントの制御外にある場合です。

  • 同じコンポーネントが同じビューポート幅で異なるレイアウトを必要とする場合は @container を使いましょう: 上記のリード/サイドバーのケースです。メディアクエリはこの違いを見ることができませんが、コンポーネント自身のラッパーにスコープされたコンテナクエリなら見ることができます。

  • デザインシステムのコンポーネントには @container を使いましょう: 何が周囲に配置されるか前提にできない、複数の製品やページで再利用するために出荷されるものすべてに当てはまります。

  • ページレベルのレイアウトには @media を使い続けましょう: ページ全体をシングルカラムとマルチカラムで切り替える、あるいはページ全体のクロームを表示・非表示にするといったマクロな判断は、どれか一つのコンポーネントのボックスに関する判断ではなく、ビューポートに関する判断です。

参考リンク

Revision History

作成更新