CSS コンテナクエリとモダンレイアウト実践:レスポンシブからコンポーネント駆動へのパラダイムシフト

前端工程

レスポンシブデザインの進化

CSS レスポンシブデザインは、メディアクエリからコンテナクエリへのパラダイムシフトを経験しました。従来の @media はビューポート幅に基づいていましたが、@container は親コンテナのサイズに基づき、コンポーネントが真に自己完結したレスポンシブ能力を実現します。

段階 技術 根拠 限界
固定レイアウト 固定ピクセル幅 なし 異なる画面に適応不可
メディアクエリ @media ビューポート幅 コンポーネントが自身のコンテナを感知できない
フレキシブルレイアウト Flexbox / Grid コンテンツ駆動 コンテナに応じたレイアウト切替不可
コンテナクエリ @container 親コンテナサイズ 真のコンポーネントレベルレスポンシブ

コンテナクエリ構文詳解

container-type プロパティ

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

.sidebar-wrapper {
  container-type: size;
}

.scroll-wrapper {
  container-type: normal;
}
意味 クエリ可能な次元
inline-size インライン方向クエリを有効化
size 双方向クエリを有効化 幅 + 高さ
normal デフォルト、クエリ無効 なし

container-name 命名

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

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

略記構文

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

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

@container ルール

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

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

@container sidebar (min-height: 500px) {
  .sidebar-nav {
    position: sticky;
    top: 1rem;
  }
}

コンテナクエリ単位

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

.card-body {
  margin-inline: 3cqi;
}
単位 意味
cqw コンテナ幅の 1%
cqh コンテナ高さの 1%
cqi コンテナインラインサイズの 1%
cqb コンテナブロックサイズの 1%
cqmin min(cqi, cqb)
cqmax max(cqi, cqb)

実践:カードコンポーネント

<div class="card-wrapper">
  <article class="card">
    <img class="card-image" src="photo.jpg" alt="サンプル" />
    <div class="card-content">
      <h3 class="card-title">コンテナクエリ実践</h3>
      <p class="card-desc">親コンテナの幅に応じてレイアウトモードを自動切替</p>
      <button class="card-btn">詳しく見る</button>
    </div>
  </article>
</div>
.card-wrapper {
  container: card / inline-size;
}

.card {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  padding: 1rem;
  border-radius: 12px;
  background: #fff;
  box-shadow: 0 2px 8px rgb(0 0 0 / 0.1);
}

.card-image {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  border-radius: 8px;
}

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

  .card-image {
    aspect-ratio: 1;
    border-radius: 12px;
  }

  .card-title {
    font-size: 1.25rem;
  }
}

@container card (min-width: 600px) {
  .card {
    grid-template-columns: 280px 1fr;
    padding: 1.5rem;
  }

  .card-title {
    font-size: 1.5rem;
  }
}

実践:サイドバーレイアウト

.page-layout {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1.5rem;
}

.sidebar-region {
  container: sidebar / inline-size;
}

@container sidebar (min-width: 280px) {
  .sidebar-nav {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 0.5rem;
  }
}

@media (min-width: 768px) {
  .page-layout {
    grid-template-columns: 260px 1fr;
  }
}

実践:ダッシュボードウィジェット

.widget-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 1rem;
}

.widget {
  container: widget / inline-size;
  padding: 1rem;
  border-radius: 12px;
  background: #f8fafc;
}

@container widget (min-width: 350px) {
  .widget-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  .widget-chart {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 1rem;
  }
}

@container widget (max-width: 349px) {
  .widget-header {
    text-align: center;
  }

  .widget-chart {
    display: flex;
    flex-direction: column;
  }
}

CSS :has() セレクタ実践

.form-group:has(input:invalid) {
  border-color: #ef4444;
}

.form-group:has(input:invalid) .error-msg {
  display: block;
}

.card:has(img) {
  grid-template-rows: auto 1fr;
}

nav:has(.submenu:hover) .submenu {
  opacity: 1;
  visibility: visible;
}

details:has([open]) .arrow-icon {
  transform: rotate(180deg);
}

.fieldset:has(:focus) {
  box-shadow: 0 0 0 3px rgb(59 130 246 / 0.3);
}

CSS ネイティブネスト構文

.card {
  padding: 1rem;
  border-radius: 12px;

  & .card-title {
    font-size: 1.25rem;
    font-weight: 700;
  }

  & .card-body {
    margin-top: 0.5rem;

    & p {
      line-height: 1.6;
    }
  }

  &:hover {
    box-shadow: 0 4px 16px rgb(0 0 0 / 0.12);
  }

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

  @media (prefers-color-scheme: dark) {
    background: #1e293b;
    color: #e2e8f0;
  }
}

Subgrid による配置

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 1.5rem;
}

.card {
  display: grid;
  grid-row: span 3;
  grid-template-rows: subgrid;
}

.card-title {
  font-size: 1.125rem;
}

.card-body {
  flex: 1;
}

.card-footer {
  margin-top: auto;
}

CSS Anchor Positioning

.tooltip-trigger {
  anchor-name: --trigger-anchor;
}

.tooltip {
  position-anchor: --trigger-anchor;
  position-area: top center;
  position-visibility: anchors-visible;
  padding: 0.5rem 0.75rem;
  background: #1e293b;
  color: #f8fafc;
  border-radius: 6px;
  font-size: 0.875rem;
}

.popover-menu {
  position-anchor: --trigger-anchor;
  position-area: bottom center;
  position-visibility: anchors-visible;
  margin-top: 4px;
  padding: 0.5rem 0;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 4px 16px rgb(0 0 0 / 0.15);
}

View Transitions API

@view-transition {
  navigation: auto;
}

::view-transition-old(root) {
  animation: fade-out 0.2s ease-out;
}

::view-transition-new(root) {
  animation: fade-in 0.3s ease-in;
}

@keyframes fade-out {
  to { opacity: 0; transform: scale(0.98); }
}

@keyframes fade-in {
  from { opacity: 0; transform: scale(1.02); }
}

.card-image {
  view-transition-name: card-img;
}

::view-transition-old(card-img) {
  animation: shrink 0.3s ease-out;
}

::view-transition-new(card-img) {
  animation: grow 0.3s ease-in;
}
document.querySelector('.nav-link').addEventListener('click', async (e) => {
  e.preventDefault();
  const transition = document.startViewTransition(async () => {
    await updateContent(e.target.href);
  });
  await transition.finished;
});

Scroll-Driven Animations

.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 3px;
  background: #3b82f6;
  transform-origin: left;
  animation: scale-x linear;
  animation-timeline: scroll();
}

@keyframes scale-x {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

.reveal-item {
  opacity: 0;
  transform: translateY(30px);
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

@keyframes reveal {
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.parallax-bg {
  animation: parallax-move linear;
  animation-timeline: scroll();
}

@keyframes parallax-move {
  from { background-position-y: 0%; }
  to { background-position-y: 100%; }
}

Cascade Layers(@layer)

@layer reset, base, components, utilities;

@layer reset {
  *, *::before, *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }
}

@layer base {
  body {
    font-family: system-ui, sans-serif;
    line-height: 1.6;
    color: #1e293b;
  }

  a {
    color: #3b82f6;
    text-decoration: none;
  }
}

@layer components {
  .btn {
    display: inline-flex;
    align-items: center;
    padding: 0.5rem 1rem;
    border-radius: 8px;
    font-weight: 500;
  }

  .btn-primary {
    background: #3b82f6;
    color: #fff;
  }
}

@layer utilities {
  .text-center { text-align: center; }
  .mt-4 { margin-top: 1rem; }
}

モダン CSS Reset

@layer reset {
  *, *::before, *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }

  html {
    hanging-punctuation: first last;
  }

  body {
    min-height: 100dvh;
    line-height: 1.5;
    -webkit-font-smoothing: antialiased;
  }

  img, picture, video, canvas, svg {
    display: block;
    max-width: 100%;
  }

  input, button, textarea, select {
    font: inherit;
  }

  p, h1, h2, h3, h4, h5, h6 {
    overflow-wrap: break-word;
  }

  :root {
    color-scheme: light dark;
  }

  @media (prefers-reduced-motion: no-preference) {
    :has(:target) {
      scroll-behavior: smooth;
    }
  }
}

論理プロパティ

.card {
  margin-block: 1rem;
  margin-inline: auto;
  padding-block: 1.5rem;
  padding-inline: 1rem;
  border-inline-start: 4px solid #3b82f6;
  inset-block-start: 0;
  inset-inline-start: 0;
  max-inline-size: 800px;
  block-size: auto;
}

.nav-link {
  margin-inline-end: 1rem;
  padding-block: 0.5rem;
  padding-inline: 1rem;
  border-block-end: 2px solid transparent;
}

.nav-link:hover {
  border-block-end-color: #3b82f6;
}
物理プロパティ 論理プロパティ 説明
margin-top margin-block-start ブロック方向の開始
margin-bottom margin-block-end ブロック方向の終了
margin-left margin-inline-start インライン方向の開始
margin-right margin-inline-end インライン方向の終了
width inline-size インライン寸法
height block-size ブロック寸法
top inset-block-start ブロック方向のオフセット
left inset-inline-start インライン方向のオフセット

ブラウザサポート(2026)

機能 Chrome Firefox Safari 状態
Container Queries 105+ 110+ 16+ ✅ 安定
コンテナクエリ単位 105+ 110+ 16+ ✅ 安定
:has() 105+ 121+ 15.4+ ✅ 安定
CSS ネイティブネスト 120+ 117+ 17.2+ ✅ 安定
Subgrid 117+ 71+ 16+ ✅ 安定
Anchor Positioning 125+ ⚠️ 実験的
View Transitions 111+ 18+ ⚠️ 部分対応
Scroll-driven Animations 115+ ⚠️ 実験的
@layer 99+ 97+ 15.4+ ✅ 安定
論理プロパティ 69+ 66+ 12.1+ ✅ 安定

プログレッシブエンハンスメント戦略

@supports (container-type: inline-size) {
  .card-wrapper {
    container: card / inline-size;
  }

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

@supports not (container-type: inline-size) {
  @media (min-width: 768px) {
    .card {
      display: grid;
      grid-template-columns: 200px 1fr;
    }
  }
}
if (CSS.supports('container-type', 'inline-size')) {
  document.documentElement.classList.add('cq-supported');
}

よくある落とし穴とデバッグ

落とし穴 1:container-type の設定忘れ

.card-wrapper {
  container-name: card;
}

@container card (min-width: 400px) {
  .card { display: grid; }
}

container-type も設定するか、略記の container: card / inline-size を使用する必要があります。

落とし穴 2:コンテナは自身をクエリできない

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

@container (min-width: 400px) {
  .card { display: grid; }
}

コンテナクエリは子孫要素にのみ作用し、container-type が設定された要素自体には作用しません。

落とし穴 3:コンテナサイズが 0

コンテナに明示的な幅がない場合(例:width: fit-content の flex 子要素)、コンテナクエリが機能しない可能性があります。コンテナに確定したサイズがあることを確認してください。

デバッグのヒント

  • Chrome DevTools → Elements パネル → @container マーカーを確認
  • size ではなく container-type: inline-size を使用して不要なレイアウト計算を回避
  • overflow: hidden がサイズ計算に影響していないか確認

パフォーマンス考慮事項

懸念事項 推奨事項
コンテナ数 深くネストされたコンテナを避ける、各レイヤーがクエリオーバーヘッドを増加
container-type inline-size を優先、size は双方向計算をトリガー
クエリブレークポイント 3-5 個に抑える、条件分岐が多すぎないよう注意
リフロー範囲 コンテナクエリはサブツリーのみに影響、メディアクエリより範囲が小さい
:has() 大量の要素に複雑な :has() セレクタを使用しない
Scroll アニメーション animation-timeline: scroll() は scroll イベントリスナーより 10 倍以上高速
View Transitions 古い/新しいスナップショットがメモリを消費、大きなページでは view-transition-name の数に注意

FAQ

Q:コンテナクエリはメディアクエリに代わるものですか?

A:完全に置き換えるものではありません。ページレベルのレイアウト(ナビバー、全体グリッド)には @media が引き続き必要で、コンポーネントレベルのレスポンシブには @container を使用します。両方を組み合わせて使用します。

Q:コンテナクエリと Flexbox/Grid の自動サイズ調整の違いは?

A:Flexbox/Grid はコンテンツ駆動(スペースがあれば配置)ですが、コンテナクエリは条件駆動(スペースが十分なら別のレイアウトモードに切替)です。

Q::has() はパフォーマンスに影響しますか?

A:単純な使用法(例::has(input:focus))のオーバーヘッドは最小限です。数千の要素に複雑な :has() チェーンセレクタを使用するのは避けてください。

Q:Subgrid と通常の Grid の違いは?

A:Subgrid は子グリッドに親グリッドのトラック定義を継承させ、カード間の配置を実現します。通常の Grid の子要素は独立しており、項目間で配置を合わせることができません。

Q:Anchor Positioning はいつ使えますか?

A:2026 年現在、Chrome 125+ がサポートしています。Firefox と Safari はまだ実装中です。本番環境では Floating UI などのライブラリをフォールバックとして使用してください。


関連ツール

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

#CSS#容器查询#现代布局#前端#教程