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 などのライブラリをフォールバックとして使用してください。
関連ツール
- Base64 エンコード/デコード — CSS 内のインライン画像データの処理
- JSON フォーマッター — CSS-in-JS 設定のフォーマット
- カラー変換 — CSS カラーフォーマットの変換
ブラウザローカルツールを無料で試す →