CSSとレイアウト

コンテナクエリ:ビューポートではなく自分のコンテナを知るコンポーネント

コンテナクエリ:ビューポートではなく自分のコンテナを知るコンポーネント

商品カードがあるとします。タイトルの上に画像、価格、そして「カートに追加」ボタン。ストアのメインのグリッドでは、画面幅が768pxを超えたところでメディアクエリによって横並びのレイアウトに切り替えます——左に画像、右にコンテンツ、そのほうが利用できる幅をうまく使えるからです。うまく動きます。ところがプロダクトの担当者が、同じカードを記事本文の横にある狭い「おすすめ商品」サイドバーに置きたいと言い出します。コンポーネントをそのまま放り込むと——崩れます。700ピクセル以上を前提に設計された横並びレイアウトが、260ピクセルの列に無理やり収まろうとする。画像は帯状に押し潰され、テキストは一行に一単語ずつ折り返されてしまいます。

CSSでミスをしたわけではありません。あなたのメディアクエリは、まさに設計どおりに動いています——ただ、間違った問いに答えているだけです。@media (min-width: 768px)が尋ねているのは*「ビューポートの幅はいくつか」*です。商品カードが正しく見えるために本来気にすべきなのは、ビューポートの幅ではなく、親から与えられたスペースの大きさのはずです。これはまったく別の二つの問いであり、CSSはその二つ目を尋ねる手段を持たなかったために、20年もの間これらを一つの問いとして扱ってきたのです。

なぜメディアクエリでは足りないのか

メディアクエリはグローバルな問いです。あなたのコンポーネントが1200ピクセルの列に座っていようと、260ピクセルの狭いサイドカードに座っていようと関係ありません——@media (min-width: 768px)はどちらの場合も同じtrueを返します。ブラウザのウィンドウに尋ねているのであって、要素の親には尋ねていないからです。したがってメディアクエリだけを基準に書かれたコンポーネントは、もともと設計されたコンテキストの中でのみ正しいものであり、同じコードがほかの場所に置かれた瞬間に正しさを失います。これはコンポーネントが本来約束すべきこと——レイアウトのどこに置いても安全に再利用できること——を根本から壊してしまいます。

2023年以前、現実的な唯一の回避策はJavaScriptのResizeObserverでした。要素を監視し、変化のたびに幅を測定し、その結果をstateに入れ、条件付きでCSSクラスを付け替える。動きはしますが、コストがかかります。コンポーネントごとの追加のstate、サイズが変わるたびの再レンダー、コンポーネントごとに手書きしなければならないコード、そしてJavaScriptが実行されるまでは存在しないコード——つまりサーバーサイドレンダリングでは、ResizeObserverが何かを測定し終える前に、最初のフレームはとにかく「勘」で描いたレイアウトを表示することになります。

jsx

// Container Queries以前の回避策 — 動くが、コストがかかる
import { useEffect, useRef, useState } from "react";
const ProductCard = ({ product }) => {
const cardRef = useRef(null);
const [isWide, setIsWide] = useState(false);
useEffect(() => {
const el = cardRef.current;
const observer = new ResizeObserver((entries) => {
setIsWide(entries[0].contentRect.width > 320);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<article ref={cardRef} className={`card ${isWide ? "card--wide" : ""}`}>
{/* ... */}
</article>
);
};

コンテナクエリは、この同じ問題をJavaScriptを一行も、stateも、再レンダーも使わずに解決します——「どれだけのスペースが使えるか」という問いが、CSSが本来ずっと尋ねてきた場所、つまりスタイルシートへと戻ってくるからです。

実際には内部で何が起きているのか:containmentであって、単なる新しい構文ではない

@containerという構文そのものは、名前が違うだけの@mediaのように見えます——しかし本当の違いは別のところにあります。ブラウザがそもそもそうした問いに答えることを許す前に、何が起きていなければならないか、という部分です。ある要素が「クエリのコンテナ」になるには、それを明示的に宣言する必要があります。

css

.card-slot {
container-type: inline-size;
container-name: card;
}
@container card (min-width: 400px) {
.card {
grid-template-columns: 40% 1fr;
}
}

container-type: inline-sizeは単なるクエリモードのスイッチではありません——これはCSS Containment、別個の仕様の宣言であり、ブラウザにこう伝えます。「この要素のコンテンツはインライン軸方向のサイズに影響を与えないので、レイアウト計算の際にこの要素を独立した島として安全に扱ってよい」。これは意味のない実装上の細部ではなく、コンテナクエリがそもそも循環せずに存在するための必要条件です。もしブラウザが、ある要素に対して自分自身の幅に応じてサイズを変えることとコンテンツを通じてその幅に影響を与えることを同時に許してしまったら、循環依存が生まれてしまいます。コンテンツがコンテナのサイズを変える→サイズの変化が@containerを切り替える→新しいルールがコンテンツを変える→コンテンツが再びコンテナのサイズを変える、という具合です。Containmentはこのループを根本で断ち切ります。ある軸におけるコンテナのサイズは、中に何が入っているかとは独立に確定される、と強制することによってです。

これはcontainer-typeの値を選ぶ際に、具体的で実務的な結果をもたらします。

  • inline-size — インライン軸(通常は水平方向)のみのcontainment。要素の高さは依然としてコンテンツから自由に導かれます。95%のケースで選ぶことになる値であり、商品カードの例でもまさにこの値を使いました。
  • size — 両方の軸に対するcontainment。要素はサイズを決めるうえで完全にコンテンツへの依存をやめます——つまり、明示的な高さ(heightaspect-ratioなど)を必ず与えなければなりません。そうしないとcontainmentが要素をコンテンツの自然な高さから切り離してしまい、高さが実質的にゼロまで落ち込みます。
  • normal — デフォルト値。containmentなし、クエリのコンテナでもない状態です。

container-nameは省略可能ですが、習慣として付けておく価値があります——これがないと、@container (min-width: 400px)直近のコンテナである祖先要素——それが何であれ——に問い合わせます。フラットなコンポーネントであれば無害ですが、入れ子になったレイアウト(パネルの中にカード、列の中にパネル——パネルも列もどちらもコンテナ)では、名前付きコンテナがあれば、DOMツリー上のたまたまの近さに頼るのではなく、正確にどの祖先に問い合わせているのかを明確にできます。

実践例:一つのカード、二つのコンテキスト、JavaScriptゼロ

冒頭で登場した商品カードに戻りましょう——今度は、埋め込まれた場所についていっさい知る必要なく、幅広のグリッドでも狭いサイドバーでも正しくレンダリングされるように組み立てます。

jsx

const ProductCard = ({ product }) => (
<div className="product-card-slot">
<article className="product-card">
<img
src={product.image}
alt={product.name}
className="product-card__image"
/>
<div className="product-card__body">
<h3 className="product-card__title">{product.name}</h3>
<p className="product-card__price">{product.price}</p>
<button type="button" className="product-card__cta">
カートに追加
</button>
</div>
</article>
</div>
);
export default ProductCard;

css

/* コンポーネントの親がクエリのコンテナとして自らを宣言する */
.product-card-slot {
container-type: inline-size;
container-name: product-card;
}
/* デフォルトのレイアウト:画像がコンテンツの上 — 狭いスペースでも安全 */
.product-card {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.product-card__image {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 8px;
}
/* ビューポートではなく「コンテナ」の幅が360pxを超えたら — 横並びレイアウトに切り替え */
@container product-card (min-width: 360px) {
.product-card {
grid-template-columns: 40% 1fr;
align-items: center;
}
.product-card__image {
aspect-ratio: 1 / 1;
height: 100%;
}
}

ここでの重要な決定は、CSSではなく構造の中に隠れています。container-type.product-card-slot、つまりカードのに置かれており、.product-card自身には置かれていません。これは偶然でも過剰なスタイルでもありません——仕様は、ある要素が@containerを通じて自分自身のサイズに問い合わせることを明確に禁じています(またしても同じ循環依存の問題です。要素は同時にコンテナを定義しつつ、そのサイズに反応してスタイルされることはできません)。だからこそ、典型的で繰り返し使えるパターンは、外側に薄いラッパーコンテナを置き、内側に実際のコンポーネントを置いて、@containerブロックの中でスタイルする、というものになります。この同じCSSファイルを、商品グリッド(スロットが480pxのところ)とサイドバー(スロットが240pxのところ)にそれぞれ一度ずつ配置すれば、結果として二つの異なる、それぞれ正しいレイアウトが得られます——propも、修飾用のクラスも、JavaScriptの一行も必要ありません。

コンテナ単位:ビューポートに頼らない可変性

コンテナクエリは、同じ仕様の中にある、あまり知られていないもう一つの要素を持ち込みました——ビューポートではなくコンテナに対して相対的な単位です。cqw(コンテナ幅の1%)、cqh(高さの1%)、cqicqb(インライン軸/ブロック軸方向の1%——つまり縦書きの言語でも正しく機能します)、そしてcqmin/cqmaxは、vmin/vmaxに相当しますが、コンテナの小さいほうまたは大きいほうの寸法を基準に計算されます。

これらの自然な用途は、ユーザーの画面がどれだけ広いかとは無関係に、コンポーネント内部で流動的なタイポグラフィを実現することです。

css

.product-card__title {
/* 画面幅ではなく、カードの幅に合わせてスケールする */
font-size: clamp(1rem, 0.85rem + 2cqi, 1.375rem);
}

vw単位を使ったおなじみのclamp()のテクニックとの違いは微妙ですが、実務上は根本的なものです。vwでスケールされたタイトルはページ全体と一緒にサイズが変わるので、同じカードの二つのコピー——一つは全幅のグリッドに、もう一つは狭いサイドバーに——はまったく同じフォントサイズになります。両方とも同じビューポートを見ているからです。cqiでスケールされたタイトルは、それぞれのカードに実際に与えられたスペースに反応します——サイドバーでは小さく、グリッドでは大きくなります。ビューポートの幅はその間ずっとまったく変わっていないにもかかわらずです。

落とし穴とグッドプラクティス

  • 明示的な高さのないcontainer-type: sizeはコンテンツを消してしまう。 ブロック軸方向のcontainmentは、要素を子要素の自然な高さから切り離します。heightaspect-ratioも指定しなければ、コンテナは実質的に高さゼロまで縮み、子要素はDOM上にはまだ存在しているのに、視覚的には消えてしまいます。大半のケースではinline-sizeで十分であり、この問題は起きません。
  • コンテナは自分自身に問い合わせることはできない。@containerが動かない」という問題に悩んでいるなら、まず疑うべきはこれです。container-typeと、@containerでスタイルしているセレクタが同じ要素に乗っている、というケースです。それらを分けましょう——container-typeはラッパーに、対象のスタイルは子要素に。ちょうど上の商品カードの例のとおりです。
  • コンテナクエリはメディアクエリを置き換えるのではなく、補完する。 ページレベルの判断(そもそもサイドバーを表示するかどうか、ナビゲーションをハンバーガーメニューに切り替えるかどうかなど)には、依然としてメディアクエリが適切なツールです。コンテナクエリは、そのコンポーネントがどこに着地しようとも、コンポーネントレベルの判断を担います。2026年のよいレイアウトは通常、両方を、それぞれが実際に尋ねている問いにふさわしい場所で使い分けています。
  • すべてのdivを「念のため」コンテナにしない。 Containmentはレンダリングエンジンにとって実際にコストがかかります——これは「私を独立したレイアウトの単位として扱ってください」というシグナルであり、実際に自分のサイズに反応するコンポーネントを持つ場所でこそ有用です。DOMツリー全体に対するデフォルト設定にすべきものではありません。
  • ブラウザのサポートはもはや問題ではない。 container-type@containerは2023年初頭にBaseline(広くサポートされている)のステータスに達しました——Chrome、Safari、Firefoxでネイティブに動作し、polyfillも@supportsも必要ありません。cqw/cqiとその仲間の単位も、まったく同じカバレッジを持っています。

まとめ

再利用可能なコンポーネントとは、着地しうるあらゆる場所を手作業で処理できるだけの十分なpropsと修飾クラスを備えたものではなく、自分がどれだけのスペースを持っているかを自ら知り、誰の助けも借りずにそれに反応できるもののことです。メディアクエリは20年もの間、CSSにおけるレスポンシブ対応の唯一の道具だったので、本来は親についての問いであるべきものにまでそれを使いすぎてしまうのは自然なことでした。コンテナクエリは同じ仕組みの別の構文バリエーションではありません——DOMツリーの正しい場所で尋ねられる別の問いであり、その答えが決して循環しないことを保証するcontainmentに支えられています。この記事のコード——一つのコンポーネント、一つのCSSファイル、JavaScriptゼロ——はグリッドでも、サイドバーでも、そして今はまだそこに置くと知らないほかのどんな場所でも、正しく動作します。