shibomb

配置場所で崩れない!CSSコンテナクエリで自律適応するカードUIを設計する

サイドバーでは崩れ、モーダルでは窮屈になる。メディアクエリ(@media)で作ったカードUIを別の場所に置いた途端に、こうした崩れが起きていませんか。そのたびに「.card—sidebar」「.card—modal」のような専用クラスを足し、CSSが膨らんでいく悩みは珍しくありません。この記事では、親要素の幅に反応できるCSSコンテナクエリ(Container Queries)を使い、置き場所を選ばないカードUIを設計する手順を、コードつきで解説します。

画面幅(@media)依存のレスポンシブがコンポーネント再利用時に破綻する理由

メディアクエリが見ているのは、ブラウザのビューポート幅です。コンポーネント自身が実際に使える幅ではありません。ここがズレの原因です。

たとえば「画面幅768px以上なら、カードを画像と文章の横並びにする」というルールを書いたとします。幅1200pxのPCでは、メイン領域に置いたカードは広く使えるので期待どおりに見えます。ところが同じカードを幅280pxのサイドバーに置くと、画面幅の条件は満たされたままなので横並びが適用されます。結果として、画像と文章が極端に狭い領域に押し込まれます。

/* 画面幅だけを見ているカード */
.card {
  display: grid;
  gap: 12px;
}

@media (min-width: 768px) {
  .card {
    grid-template-columns: 200px 1fr; /* サイドバーでは窮屈になる */
  }
}

この崩れを避けようとして場所別の修飾クラスを増やすと、コンポーネントが「どこに置かれるか」を知っている状態になります。置き場所が増えるたびにCSSを書き足すことになり、チーム開発では保守コストが積み上がります。コンポーネントは自分の置かれた領域の広さに応じて自分で形を決める、という設計に変えるのが解決の方向です。それを実現する仕組みがコンテナクエリです。

コンテナクエリの基礎:container-type と container-name の役割を理解する

コンテナクエリは、ビューポートではなく「祖先の要素(コンテナ)のサイズ」を条件にスタイルを切り替える機能です。仕様はCSS Containment Module Level 3で定義され、サイズに基づくコンテナクエリは2023年前半に主要ブラウザ(Chrome、Safari、Firefox、Edge)で出そろっています。現在はモダンCSSの標準的な選択肢として使えます。導入前に、自分のプロジェクトの対象ブラウザをMDNなどの互換性表で確認してください。

使うには2つのプロパティを理解すれば十分です。

  1. container-type:その要素を「測定対象のコンテナ」にします。実務では、横幅だけを見る inline-size が基本です。
  2. container-name:コンテナに名前を付けます。@container の条件でどのコンテナを見るかを指定できます。
.card-slot {
  container-name: card;
  container-type: inline-size;
}

/* 一括指定(名前 / タイプ) */
.card-slot {
  container: card / inline-size;
}

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

注意点が1つあります。@container の中で書いたスタイルが適用されるのは、コンテナの 子孫 要素です。コンテナ自身のスタイルは変えられません。そのため「測定される外側の箱(card-slot)」と「中身(card)」を分ける構造にします。また inline-size を指定した要素は、中身の量から横幅を決められなくなります。block 要素なら親の幅いっぱいに広がるので問題ありませんが、flex の子要素として使う場合は幅が潰れないよう、flex: 1 や明示的な幅を与えてください。

実践作成:メイン領域でもサイドバーでも美しく適応する「万能カードコンポーネント」

では実際に作ります。方針は「狭いときは縦積み、中くらいで横並び、広ければ余白と文字を大きく」です。まずHTMLです。

<div class="card-slot">
  <article class="card">
    <img class="card__thumb" src="thumb.jpg" alt="記事のサムネイル">
    <div class="card__body">
      <h3 class="card__title">コンテナクエリ入門</h3>
      <p class="card__text">親要素の幅に応じて形が変わるカードです。</p>
      <a class="card__link" href="#">続きを読む</a>
    </div>
  </article>
</div>

次にCSSです。基本形はいちばん狭い縦積みにして、広くなるほど要素を足していきます。

.card-slot {
  container: card / inline-size;
}

.card {
  display: grid;
  gap: 12px;
  padding: 16px;
  border: 1px solid #ddd;
  border-radius: 8px;
}

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

.card__title {
  font-size: clamp(1rem, 4cqi, 1.5rem);
}

.card__text {
  display: none; /* 狭いときは省略 */
}

@container card (min-width: 360px) {
  .card__text {
    display: block;
  }
}

@container card (min-width: 520px) {
  .card {
    grid-template-columns: 200px 1fr;
    align-items: start;
  }
  .card__thumb {
    aspect-ratio: 1;
  }
}

@container card (min-width: 760px) {
  .card {
    grid-template-columns: 280px 1fr;
    padding: 24px;
    gap: 24px;
  }
}

card__title の cqi は、コンテナクエリと同時に使える長さの単位です。コンテナのインライン方向の幅の1%を表します。画面幅ではなくカード自身の幅に応じて文字サイズを調整できるので、clamp と組み合わせて上限と下限を決めておくと安全です。

このカードを幅280pxのサイドバーにそのまま置くと、縦積みで本文なしの簡潔な表示になります。幅600pxのメイン領域では横並びになり、モーダル内でも同様にその幅に合った表示になります。HTMLもCSSも一切変更していません。新しい置き場所が増えても、専用クラスを書く必要はありません。

画面全体の骨組み(メディアクエリ)と部品(コンテナクエリ)の使い分けルール

コンテナクエリを導入しても、メディアクエリは不要になりません。役割が違うだけです。判断基準は「何の状態に応じて変えたいか」です。

変えたい対象使う仕組み例
ページ全体のレイアウト@mediaサイドバーの有無、ヘッダーのナビ切り替え
部品の内部レイアウト@containerカードの縦横切り替え、文字サイズ
ユーザー環境の設定@mediaprefers-reduced-motion、prefers-color-scheme、hover

ページの骨組みは、画面幅に応じてメディアクエリで決めます。たとえば1024px以上で2カラムにし、サイドバーを表示します。その中に置かれたカードは、割り当てられた幅を見て勝手に最適化されます。

.layout {
  display: grid;
  gap: 24px;
}

@media (min-width: 1024px) {
  .layout {
    grid-template-columns: 1fr 300px; /* 骨組みは@mediaで決める */
  }
}

この分担にすると、ページ側は「どこにどれだけの幅を割り当てるか」だけを責務とし、部品側は「与えられた幅でどう見せるか」だけを責務とします。責任が切り分けられるため、レイアウト変更時にコンポーネントを触る必要がなくなります。ダークモードやアニメーション抑制のようなユーザー設定は、コンテナの幅とは無関係なので引き続きメディアクエリの担当です。

保守性を損なわないための命名規約とコンテナ設計の注意点

コンテナクエリは便利ですが、設計が雑だと別の混乱を生みます。チームで運用するときは、次の点を決めておくと安定します。

  1. container-name はコンポーネントの役割で付けます。 「sidebar」のような置き場所の名前は避け、「card」「media-list」のように中身を表す名前にします。置き場所の名前を付けると、修飾クラスと同じ依存関係が再び生まれます。
  2. 名前を省略しません。 名前なしの @container は、最も近い祖先のコンテナを見ます。あとから途中の要素にコンテナを足すと、意図せず測定対象が変わります。名前を付けておけば、この事故を防げます。
  3. コンテナ要素と見た目の要素を分けます。 本記事の card-slot のように、測定用のラッパーを用意します。コンテナ自身にはクエリが効かないため、この分離が最も扱いやすい形です。
  4. container-type は原則 inline-size にします。 size を指定すると高さも封じ込められ、高さを明示しないと潰れる場合があります。縦方向の判定が必要な場面は多くないので、まずは inline-size で足ります。
  5. ブレークポイントの値を共有します。 @container の条件には、メディアクエリと同様にカスタムプロパティ(var)を書けません。値を使い回したい場合は、CSSの冒頭にコメントで一覧を残すか、Sassなどのビルド時変数を使ってください。

もう1つ、見落としやすい副作用があります。container-type を指定すると、その要素にレイアウトの封じ込め(layout containment)が適用されます。このため、中にある position: absolute や fixed の子孫要素の基準位置がコンテナになります。ドロップダウンやツールチップが予想外の位置に出たときは、祖先の container-type を疑ってください。

導入するときは、既存のカードやリストなど、再利用頻度が高い部品から1つずつ置き換えるのが安全です。デベロッパーツールでは、コンテナとして扱われている要素にバッジが表示されるブラウザもあるので、どの要素が測定対象になっているかを確認しながら進められます。

関連記事