CSS

コンテナスタイルクエリでカスタムプロパティをフラグとして扱う

@container style()(コンテナスタイルクエリ)は祖先の要素に宣言したカスタムプロパティの値を条件にして、子孫のスタイルを切り替える仕組みです。サイズクエリが幅を問うのに対して、スタイルクエリはカスタムプロパティの値を問います。

この仕組みを使うと、カスタムプロパティを値の入れ物ではなく条件(フラグ)として扱えます。幅やホバーの状態を1つの要素でフラグに置き換えておけば、子孫はフラグの値を読むだけで見た目をそろって切り替えられます。

幅で切り替えるだけなら、使い慣れたサイズクエリで足りるようにも見えます。困るのは同じ幅で変えたい子要素が増えたときです。

要素ごとのルールにサイズクエリを書くと閾値が散らばる

コンテナサイズクエリはcontainerプロパティでコンテナにした祖先の幅を条件にして、子孫のスタイルを切り替えます。例えば、このブログではコンポーネントのルートにcontainer: --scoped / inline-sizeを指定し、子孫が@container --scoped (… <= inline-size)で問い合わせる形にしています。

以下は、サムネイル、日付、題、説明を持つ記事カードのCSSの例です。@layerと@scopeはこのブログのCSSを整理するための書き方で、コンテナクエリ自体には要りません。広い場所に置いたときはサムネイルを横に並べて正方形にし、題の文字を大きくしたいとします。ルート(:scope)をコンテナにし、変えたい子要素それぞれのルールの中にサイズクエリを書くと、同じ閾値のクエリが子要素の数だけ並びます。

/* 子要素ごとに同じ閾値のサイズクエリを書く(閾値が散らばる例) */
@layer components {
	@scope (.scoped.article-card) to (.scoped) {
		:scope {
			container: --scoped / inline-size;
		}

		._layout {
			display: block grid;

			/*
			 * 閾値: サムネイルを横に並べたときに、本文の列に 144px 相当が残るコンテナの幅。
			 * 内訳は本文の列(rem なので文字の大きさに追従)、サムネイル 96px、
			 * 横並びのときの gap 16px、左右の padding 16px × 2。
			 * gap と padding の宣言は、この例では省いている
			 */
			@container --scoped (calc(144 / 16 * 1rem + 96px + 16px + 32px) <= inline-size) {
				grid-template-columns: 96px 1fr;
			}
		}

		._thumbnail {
			aspect-ratio: 16 / 9;

			@container --scoped (calc(144 / 16 * 1rem + 96px + 16px + 32px) <= inline-size) {
				aspect-ratio: 1;
			}
		}

		._title {
			font-size: var(--text--subheading--fluid-font-size);

			@container --scoped (calc(144 / 16 * 1rem + 96px + 16px + 32px) <= inline-size) {
				font-size: var(--text--heading--fluid-font-size);
			}
		}
	}
}

同じcalc()の閾値が3か所にあります。閾値を変えるときは3か所とも直すことになり、1か所でも直し忘れると、横並びなのに題の文字は小さいままのような、どのデザインにもない中間の状態ができます。

閾値をカスタムプロパティに入れて共有する方法も、いまは採れません。仕様(新しいタブで開く)はサイズクエリの条件でもカスタムプロパティを参照できると定めていますが、2026年11月時点では対象のブラウザすべてでは使えないためです。

サイズクエリを要素ごとのルールに分けず、末尾の1つのクエリに子のルールをまとめれば、閾値は1か所になります。ただし、今度は1つの要素の見た目が、要素のルールと末尾のクエリの2か所に分かれます。閾値を1か所にまとめることと、要素の見た目をその要素のルールにまとめることを両立させたいのです。まとめる方法との違いは、後の節で比べます。

スタイルクエリが条件にするもの

閾値を1か所にまとめるには、幅を判定した結果を子孫に伝える手段が要ります。CSSで子孫に値を伝えるといえば、継承するカスタムプロパティです。スタイルクエリはこのカスタムプロパティの値を条件にできます。

書き方は@container style(--名前: 値)です。問い合わせたコンテナのカスタムプロパティの計算値が、指定した値と一致するときだけ中のスタイルが当たります。仕様はCSS Conditional Rules Module Level 5のStyle Container Queries(新しいタブで開く)に、使い方はMDNのUsing container size and style queries(新しいタブで開く)にまとまっています。

サイズクエリと違って、containerの指定は要りません。仕様ではcontainer-typeが初期値のnormalの要素も、サイズは問い合わせられないもののスタイルは問い合わせられるコンテナとされています。つまり、すべての要素がスタイルクエリのコンテナです。

名前を付けない@container style()はいちばん近い祖先のコンテナを問い合わせます。すべての要素がコンテナなので、問い合わせ先は実質的に親です。もっと上の祖先で立てたフラグも継承によって親の計算値になっているので、そのまま読めます。

逆に@container --scoped style(…)と名前を付けると、--scopedという名前を持ついちばん近い祖先が問い合わせ先になります。この例ではそれがコンポーネントのルートなので、ルートより内側で立てたフラグを読めなくなります。

幅の状態をフラグにする

記事カードの例をスタイルクエリで書き直します。サイズクエリを書くのは、フラグ--_densityを宣言する._layoutのルールだけです。

._layout {
	--_density: --compact;

	display: block grid;
	gap: calc(var(--spacing--xs) * 1px);
	padding: calc(var(--spacing--sm) * 1px);

	/*
	 * 閾値: サムネイルを横に並べたときに、本文の列に 144px 相当が残るコンテナの幅。
	 * 内訳は本文の列(rem なので文字の大きさに追従)、サムネイル 96px、
	 * 横並びのときの gap 16px、左右の padding 16px × 2。
	 * 条件の中の var() は対象のブラウザすべてでは使えないので、
	 * トークンの値を直接書く。
	 * ルートは自分の幅を条件にできないため、
	 * フラグはルートの子孫の ._layout に置く
	 */
	@container --scoped (calc(144 / 16 * 1rem + 96px + 16px + 32px) <= inline-size) {
		--_density: --spacious;

		grid-template-columns: 96px 1fr;
		gap: calc(var(--spacing--sm) * 1px);
	}
}

._layoutはクエリの外で初期値の--_density: --compactを宣言し、閾値より広いときにクエリの中で--spaciousに切り替えます。

フラグをルートに置かないのは、ルートに書いたサイズクエリがルート自身ではなくさらに外側の--scopedのコンテナを問い合わせるためです。コンテナは自分の幅を条件に自分の見た目を変えられません。変えられると、見た目を変えた結果で幅が変わり、その幅でまた条件が変わる循環が起こるためです。

ほかの子孫は狭い幅の見た目をクエリなしで書き、広い幅の見た目だけを@container style(--_density: --spacious)の中に書きます。題のルールは次のとおりです。

._title {
	font-size: var(--text--subheading--fluid-font-size);
	font-weight: var(--font-weight--bold);
	line-height: var(--text--subheading--leading);

	@container style(--_density: --spacious) {
		font-size: var(--text--heading--fluid-font-size);
		line-height: var(--text--heading--leading);
	}
}

題のルールには閾値が出てきません。サムネイル、日付、説明も同じ形で、フラグの値だけを読みます。

実装例では同じカードを2つの枠に入れて縦に積んでいます。上の枠は閾値(標準の文字サイズで288px)より広く、下の枠の最大幅は240pxです。閾値を超えているのは上の枠だけなので、上のカードだけが横並びと正方形のサムネイル、日付と題と説明の大きな文字にそろって切り替わります。

幅のフラグで記事カードを切り替える
<div class="scoped demo-frames">
	<div>
		<article class="scoped article-card">
			<div class="_layout">
				<img
					class="_thumbnail"
					src="https://picsum.photos/id/1018/640/360"
					alt=""
					width="640"
					height="360"
				/>
				<div class="_body">
					<time class="_date" datetime="2026-11-11">2026年11月11日</time>
					<h3 class="_title">部品の幅に合わせて見せ方を変える</h3>
					<p class="_description">
						置き場所の幅が広いときは画像を横に並べ、文字も大きくします。狭いときは縦に積みます。
					</p>
				</div>
			</div>
		</article>
	</div>
	<div class="_narrow-frame">
		<article class="scoped article-card">
			<div class="_layout">
				<img
					class="_thumbnail"
					src="https://picsum.photos/id/1018/640/360"
					alt=""
					width="640"
					height="360"
				/>
				<div class="_body">
					<time class="_date" datetime="2026-11-11">2026年11月11日</time>
					<h3 class="_title">部品の幅に合わせて見せ方を変える</h3>
					<p class="_description">
						置き場所の幅が広いときは画像を横に並べ、文字も大きくします。狭いときは縦に積みます。
					</p>
				</div>
			</div>
		</article>
	</div>
</div>
/**
 * 記事カード。幅のフラグ --_density を ._layout の 1 か所で決め、
 * ほかの子孫はそのフラグを @container style() で読んで見た目を切り替える。
 */
@layer components {
	/* 実装例のための枠。カードの幅だけを決める */
	@scope (.scoped.demo-frames) to (.scoped) {
		:scope {
			display: block grid;
			gap: calc(var(--spacing--md) * 1px);
		}

		/* 閾値より十分に狭い固定の最大幅 */
		._narrow-frame {
			max-inline-size: 240px;
		}
	}

	@scope (.scoped.article-card) to (.scoped) {
		:scope {
			container: --scoped / inline-size;
			border-radius: 8px;
			background-color: var(--background--surface);
		}

		._layout {
			--_density: --compact;

			display: block grid;
			gap: calc(var(--spacing--xs) * 1px);
			padding: calc(var(--spacing--sm) * 1px);

			/*
			 * 閾値: サムネイルを横に並べたときに、本文の列に 144px 相当が残るコンテナの幅。
			 * 内訳は本文の列(rem なので文字の大きさに追従)、サムネイル 96px、
			 * 横並びのときの gap 16px、左右の padding 16px × 2。
			 * 条件の中の var() は対象のブラウザすべてでは使えないので、
			 * トークンの値を直接書く。
			 * ルートは自分の幅を条件にできないため、
			 * フラグはルートの子孫の ._layout に置く
			 */
			@container --scoped (calc(144 / 16 * 1rem + 96px + 16px + 32px) <= inline-size) {
				--_density: --spacious;

				grid-template-columns: 96px 1fr;
				gap: calc(var(--spacing--sm) * 1px);
			}
		}

		._thumbnail {
			inline-size: -webkit-fill-available;
			inline-size: stretch;
			aspect-ratio: 16 / 9;
			object-fit: cover;

			@container style(--_density: --spacious) {
				aspect-ratio: 1;
			}

			/* 強制カラーモードでは枠線の色が置き換わり、画像を覆うため当てない */
			@media (forced-colors: none) {
				outline: 1px solid var(--border--base);
				outline-offset: -1px;
			}
		}

		._body {
			display: block flex;
			flex-direction: column;
			gap: calc(var(--spacing--xxs) / 16 * 1rem);
		}

		._date {
			color: var(--foreground--muted);
			font-size: calc(var(--text--label--font-size) / 16 * 1rem);
			line-height: var(--text--label--leading);

			@container style(--_density: --spacious) {
				font-size: var(--text--body-compact--fluid-font-size);
				line-height: var(--text--body-compact--leading);
				text-box-trim: trim-start;
			}
		}

		._title {
			font-size: var(--text--subheading--fluid-font-size);
			font-weight: var(--font-weight--bold);
			line-height: var(--text--subheading--leading);

			@container style(--_density: --spacious) {
				font-size: var(--text--heading--fluid-font-size);
				line-height: var(--text--heading--leading);
			}
		}

		._description {
			font-size: var(--text--body-compact--fluid-font-size);
			line-height: var(--text--body-compact--leading);

			@container style(--_density: --spacious) {
				font-size: var(--text--body--fluid-font-size);
				line-height: var(--text--body--leading);
			}
		}
	}
}

マウスかキーボードでコードを選ぶと編集でき、表示結果に反映されます。編集中は Tab キーで字下げし、Esc キーでエディターから抜けます。

閾値を変えるときに直すのは._layoutのクエリの1か所だけです。幅の段階が3つ要るときは--regularのような値を足します。

フラグを立てた要素自身の見た目

._layoutは自分のgrid-template-columnsをサイズクエリの中に直接書いていました。ほかの子孫と同じく@container style()で書けば、広い幅の見た目の書き方がすべての要素でそろいそうです。ところが、そう書いても切り替わりません。

/* フラグを立てた要素自身に @container style() を書く(切り替わらない例) */
@layer components {
	@scope (.scoped.article-card) to (.scoped) {
		:scope {
			container: --scoped / inline-size;
		}

		._layout {
			--_density: --compact;

			display: block grid;

			/*
			 * 閾値: サムネイルを横に並べたときに、本文の列に 144px 相当が残るコンテナの幅。
			 * 内訳は本文の列(rem なので文字の大きさに追従)、サムネイル 96px、
			 * 横並びのときの gap 16px、左右の padding 16px × 2。
			 * gap と padding の宣言は、この例では省いている
			 */
			@container --scoped (calc(144 / 16 * 1rem + 96px + 16px + 32px) <= inline-size) {
				--_density: --spacious;
			}

			/* ._layout が問い合わせるのは親の :scope なので、自分が立てたフラグは読めない */
			@container style(--_density: --spacious) {
				grid-template-columns: 96px 1fr;
			}
		}
	}
}

@containerはコンテナの子孫に条件を付ける仕組みです。._layoutに書いた@container style()が問い合わせるのは親の:scope(カードのルート)で、._layout自身ではありません。この例では:scopeにも、その祖先にも--_densityの宣言がないので、条件は成り立たないままです。ただし、カードの外側で--_density: --spaciousが宣言されていると、:scopeがその値を受け継ぎ、._layout自身のフラグが--compactのままでも条件が成り立ってしまいます。そのため、フラグを立てた要素自身の見た目はフラグを切り替えるのと同じ条件の中に直接書きます。

擬似要素は事情が違います。擬似要素はCSSで生成される内包された要素であるため、::before、::after、::markerのスタイルクエリは元の要素を問い合わせます。リンクに立てたフラグで、そのリンクの::afterを切り替えられるということです。

自分の値で自分を切り替えたいときはif()関数でif(style(--_density: --spacious): 24px; else: 12px)のように書く方法もあります。ただし、2026年11月時点で対応しているのはChromeとEdge(137以降)だけで、FirefoxとSafariは対応していません。

CSS if() 関数

一部のブラウザのみ

執筆時点(2026年11月)から変わっていません

137
137
未対応
未対応
未対応

web-features 3.44.0 のデータ

webstatus.dev で見る

ホバーとフォーカスの状態をフラグにする

幅のほかに子孫にまとめて伝えたい状態としてよくあるのが、リンクのホバーとフォーカスです。従来はリンクの状態のセレクタから子孫を選んで書いていました。

/*
 * 親の状態のセレクタから選ぶ従来の書き方
 * (2 つの条件を対象の数だけ書き写し、詳細度も子自身のルールより高くなる例)
 */
@layer components {
	@scope (.scoped.related-links) to (.scoped) {
		a {
			&:any-link:hover ._label {
				@media (any-hover) {
					text-decoration-line: underline;
				}
			}

			&:focus-visible ._label {
				text-decoration-line: underline;
			}

			&:any-link:hover::after {
				@media (any-hover) {
					translate: calc(var(--spacing--xxxs) * 1px) 0;
				}
			}

			&:focus-visible::after {
				translate: calc(var(--spacing--xxxs) * 1px) 0;
			}
		}
	}
}

この書き方ではホバーと:focus-visibleの2つの条件を、ラベルと矢印(::after)のように見た目を変えたい対象の数だけ書き写すことになります。詳細度も子自身のルールの0.1.0に対して、&:any-link:hover ._label(展開するとa:any-link:hover ._label)は0.3.1に上がります。子の見た目が子のルールと親の状態のルールの2か所に分かれるので、子のルールだけを読んでもどの状態でどう変わるかが分かりません。

フラグにすると、2つの条件を書くのはフラグを立てるリンクの1か所だけになります。実装例ではリンクがホバーと:focus-visibleのときに--_is-hocus: --trueを立て、矢印とラベルがそれを読みます。hocusは、hoverとfocusをまとめた呼び名です。

ホバーとフォーカスのフラグ
<nav class="scoped related-links" aria-label="関連リンク">
	<ul>
		<li>
			<a href="#size-queries"><span class="_label">コンテナサイズクエリの使い方</span></a>
		</li>
		<li>
			<a href="#style-queries"><span class="_label">コンテナスタイルクエリの使い方</span></a>
		</li>
		<li>
			<a href="#has-selector"><span class="_label">:has() で子の状態を親に伝える</span></a>
		</li>
	</ul>
</nav>
@layer base {
	:root {
		/*
		* 押せる範囲の最小の大きさ。WCAG 2.2 の 24px を下限にし、粗いポインターでは 44px にする(hit-area-min-size)
		*/
		--hit-area--min-size: max(24px, calc(24 / 16 * 1rem));

		@media (any-pointer: coarse) {
			--hit-area--min-size: max(44px, calc(44 / 16 * 1rem));
		}
	}
}

/**
 * リンクの一覧。ホバーとフォーカスの状態を --_is-hocus に集め、
 * 矢印とラベルはそのフラグを @container style() で読んで見た目を切り替える。
 */
@layer components {
	@scope (.scoped.related-links) to (.scoped) {
		a {
			--_background--hocus: var(--background--hover);

			/* 外側のコンポーネントの値を継承しないよう、初期値を宣言する */
			--_is-hocus: --false;

			/* 矢印をラベルの行末から離さないため、折り返さない */
			display: block grid;
			grid-template-columns: 1fr 1ch;
			column-gap: 1em;
			align-items: center;
			min-block-size: var(--hit-area--min-size);
			padding-block: calc(var(--spacing--xs) * 1px);
			padding-inline: calc(var(--spacing--sm) * 1px);
			border-radius: 8px;
			font-size: var(--text--body-compact--fluid-font-size);
			line-height: var(--text--body-compact--leading);

			&:any-link:hover {
				/*
				 * タッチだけの端末でホバーの状態が残らないよう、
				 * ホバーできる入力機器がない環境では当てない
				 */
				@media (any-hover) {
					--_is-hocus: --true;

					/* 自分自身は問い合わせられないので、背景は状態のセレクタに直接書く */
					background-color: var(--_background--hocus);
				}
			}

			/* 矢印の移動は、キーボードの移動では動かさずに切り替える */
			&:not(:focus-visible)::after {
				@media (prefers-reduced-motion: no-preference) {
					transition-duration: var(--duration--base);
					transition-property: translate;
					transition-timing-function: var(--ease--out-quint);
				}
			}

			&:focus-visible {
				--_is-hocus: --true;

				background-color: var(--_background--hocus);
				outline: 2px solid;
				outline-offset: -2px;
			}

			&::after {
				content: "";
				aspect-ratio: 1;
				border-block-end: 2px solid;
				border-inline-end: 2px solid;
				rotate: -45deg;

				@container style(--_is-hocus: --true) {
					translate: calc(var(--spacing--xxxs) * 1px) 0;
				}
			}
		}

		._label {
			@container style(--_is-hocus: --true) {
				color: var(--foreground--accent);
				text-decoration-line: underline;
			}
		}
	}
}

マウスかキーボードでコードを選ぶと編集でき、表示結果に反映されます。編集中は Tab キーで字下げし、Esc キーでエディターから抜けます。

ラベルは状態のセレクタを使わずに、フラグだけを読みます。

._label {
	@container style(--_is-hocus: --true) {
		color: var(--foreground--accent);
		text-decoration-line: underline;
	}
}

従来の書き方と違い、状態による変化がラベルのルールの中にすべてあり、詳細度も._labelの0.1.0のままです。矢印はリンクの::afterなので、元の要素であるリンクのフラグを読みます。

一方、リンク自身の背景色は@container style()では書けません。自分自身は問い合わせられないので、&:any-link:hoverと&:focus-visibleの中に直接書いています。ホバーの条件を@media (any-hover)で囲んでいるのは、ホバーできる入力機器がない環境でフラグを立てないためです。any-hoverは使える入力機器のどれかがホバーできるかを調べるので、タッチとマウスを併用する端末では、タッチで操作しているときにも条件が成り立ちます。

子の状態を親に伝える:has()

フラグを立てたい要素が、状態を持つ要素の祖先にあたることもあります。記事一覧の行がその例です。各行で押せるのは題のリンクだけで、::afterで押せる範囲を行全体に広げています。状態を持つのはリンクですが、見た目を変えたいのは日付と行末の矢印です。日付と矢印は題のリンクの兄弟なので、リンクに立てたフラグを読めません。

そこで、行(li)が&:has(:any-link:hover)(@media (any-hover)の中)と&:has(:focus-visible)で--_has-hocus: --trueを立て、日付と矢印がそれを読みます。:has()で子の状態を親に上げてから、フラグで子孫に配る形です。

子の状態を受けるフラグ
<ul class="scoped article-list">
	<li>
		<time datetime="2026-10-12">2026.10.12</time>
		<a href="#size-queries">コンテナサイズクエリの使い方</a>
		<span class="_link-indicator" aria-hidden="true"></span>
	</li>
	<li>
		<time datetime="2026-09-28">2026.09.28</time>
		<a href="#style-queries">コンテナスタイルクエリで、親の状態を子孫に配る</a>
		<span class="_link-indicator" aria-hidden="true"></span>
	</li>
	<li>
		<time datetime="2026-09-03">2026.09.03</time>
		<a href="#has-selector">:has() で子の状態を親に伝える</a>
		<span class="_link-indicator" aria-hidden="true"></span>
	</li>
</ul>
/**
 * 記事の一覧。題のリンクの状態を、行(li)が :has() で --_has-hocus に集める。
 * 日付と行末の矢印はリンクの兄弟なので、リンク自身のフラグでは届かない。
 * 行のフラグを @container style() で読んで、見た目を切り替える。
 */
@layer components {
	@scope (.scoped.article-list) to (.scoped) {
		li {
			--_background--hocus: var(--background--hover);

			/* 外側のコンポーネントの値を継承しないよう、初期値を宣言する */
			--_has-hocus: --false;

			/* ::after で広げたリンクの範囲の基準にする */
			position: relative;
			display: block grid;
			grid-template-columns: max-content 1fr 1ch;
			column-gap: calc(var(--spacing--sm) * 1px);
			align-items: baseline;
			padding-block: calc(var(--spacing--xs) * 1px);
			padding-inline: calc(var(--spacing--sm) * 1px);
			border-block-end: 1px solid var(--border--base);
			font-size: var(--text--body-compact--fluid-font-size);
			line-height: var(--text--body-compact--leading);

			&:has(:any-link:hover) {
				/*
				 * タッチだけの端末でホバーの状態が残らないよう、
				 * ホバーできる入力機器がない環境では当てない
				 */
				@media (any-hover) {
					--_has-hocus: --true;

					/* 行自身は問い合わせられないので、背景は :has() のルールに直接書く */
					background-color: var(--_background--hocus);
				}
			}

			&:has(:focus-visible) {
				--_has-hocus: --true;

				background-color: var(--_background--hocus);
				outline: 2px solid;
				outline-offset: -2px;
			}
		}

		time {
			color: var(--foreground--muted);
			font-variant-numeric: tabular-nums;

			@container style(--_has-hocus: --true) {
				color: var(--foreground--base);
			}
		}

		a {
			/*
			 * リンクの代わりに行へリングを描くので、リンクのリングは消す。
			 * ブラウザの標準のリング(outline-style: auto)は outline-color だけでは消えないので、線種も指定する。
			 * 強制カラーモードでは透明の outline も表示される
			 */
			&:focus-visible {
				outline: 2px solid transparent;
			}

			/* 押せる範囲を行全体に広げる */
			&:any-link::after {
				content: "";
				position: absolute;
				inset: 0;
			}
		}

		._link-indicator {
			aspect-ratio: 1;

			/* 題が折り返しても、矢印を 1 行目の中央に置く */
			margin-block: calc((1lh - 1ch) / 2);
			border-block-end: 2px solid;
			border-inline-end: 2px solid;
			rotate: -45deg;

			@container style(--_has-hocus: --false) {
				opacity: 0;
			}
		}
	}
}

マウスかキーボードでコードを選ぶと編集でき、表示結果に反映されます。編集中は Tab キーで字下げし、Esc キーでエディターから抜けます。

実装例のCSSのうち、フラグを立てる部分と、日付と矢印がフラグを読む部分は次のとおりです。

/* 実装例の CSS から、フラグを立てる部分と読む部分を抜き出したもの(配置と装飾は省略) */
@layer components {
	@scope (.scoped.article-list) to (.scoped) {
		li {
			--_background--hocus: var(--background--hover);

			/* 外側のコンポーネントの値を継承しないよう、初期値を宣言する */
			--_has-hocus: --false;

			&:has(:any-link:hover) {
				/* ホバーできる入力機器がない環境ではフラグを立てない */
				@media (any-hover) {
					--_has-hocus: --true;

					/* 行自身は問い合わせられないので、背景は :has() のルールに直接書く */
					background-color: var(--_background--hocus);
				}
			}

			&:has(:focus-visible) {
				--_has-hocus: --true;

				background-color: var(--_background--hocus);
				outline: 2px solid;
				outline-offset: -2px;
			}
		}

		/* 日付と矢印は、リンクの兄弟として行のフラグを読む */
		time {
			color: var(--foreground--muted);

			@container style(--_has-hocus: --true) {
				color: var(--foreground--base);
			}
		}

		._link-indicator {
			@container style(--_has-hocus: --false) {
				opacity: 0;
			}
		}
	}
}

行は自分自身を問い合わせられないので、行の背景色とフォーカスリングは:has()のルールの中に直接書いています。矢印は--_has-hocus: --falseのときだけopacity: 0にしているので、スタイルクエリに対応していない環境では出たままになります。

このブログの記事一覧では、行に加えて年ごとのまとまり(ul)にも同じ形でフラグを立てています。年ごとのまとまりは&:has(:any-link:hover)(@media (any-hover)の中)と&:has(:focus-visible)でフラグを立て、行の背景を描く::afterを--_has-hocus: --falseのときにopacity: 0にしています。背景を年ごとに1枚にして、ホバーかフォーカスした行へアンカーポジショニングで移すためです。

要素自身の状態を表すフラグを--_is-hocus、子孫の状態を受けるフラグを--_has-hocusと呼び分けておくと、フラグがどの要素の状態かを名前で見分けられます。このブログのコードで使っているのは、子孫の状態を受ける--_has-hocusのほうです。

フラグの値の決め方

値は--で始まる識別子にする

フラグの値には、--compactや--trueのように--で始まる識別子を使っています。この決まりはフラグの値に限らず、@keyframesの名前、グリッドの線やエリアの名前、アンカーの名前、コンテナの名前のように、自分で付けて値として書く名前はすべて--で始めています。

--で始める理由は4つあります。

  1. 自分で付けた名前に--を求めるプロパティが増えていることです。anchor-nameやスクロール連動アニメーションのタイムラインの名前は、--で始めないと無効になります。
  2. すべての名前を--で始めておけば、どのプロパティで--が要るかを1つずつ覚えなくて済むことです。
  3. CSSに新しいキーワードが足されても、自分で付けた名前とぶつからないことです。CSSのキーワードは--で始まらないので、--を付けた名前とは重なりません。
  4. 読んだときに自分で付けた名前だとすぐ分かることです。compactやtrueだけでは、CSSのキーワードなのか自分で決めた値なのかを見た目で区別できません。

--を付けるのは、値として書く名前だけです。@layerのレイヤー名のように、プロパティの値として使わない名前には付けません。

オンとオフは--trueと--falseにそろえる

スタイルクエリは値が一致するかを調べるだけです。1やtrueが混ざって書き間違えても、エラーにはならずに条件が成り立たないだけなので、気付きにくい間違いになります。そのため、オンとオフの2値は--trueと--falseにそろえます。

2値でも状態の名前そのものを値にしたほうが分かりやすいものは、その名前を使います。例えば、このブログの配色のフラグの値は--lightと--darkです。--darkに切り替える条件のコードは、「このブログでの使い方」の節に載せています。

@layer base {
	:root {
		/* 色以外を配色で変えるときにスタイルクエリで参照する */
		--color-scheme--current: --light;
	}
}

初期値をクエリの外に書く

実装例の--_density: --compactと--_is-hocus: --falseは、どちらもクエリの外に書いた初期値です。カスタムプロパティは継承するので、初期値を書かないと、外側のコンポーネントで同じ名前のフラグが立っていたときにその値を受け継ぎます。外側のコンポーネントが--spaciousのとき、狭い場所に置いた内側のカードまで広い幅の見た目になる、といったことが起こります。

範囲の比較はまだ使わない

style(--_columns > 2)のように範囲を比べる書き方もあります。ただし、2026年11月時点で対応しているのはChromeとEdge(142以降)とFirefox(151以降)で、Safariは対応していません。そもそも、状態を識別子で表して値の一致だけで判定するなら、範囲の比較は要りません。

このブログでの使い方

このブログで使用されているスタイルクエリを用いたフラグ管理例です。

フラグ値伝える内容
--_density--compact / --spacious置かれた場所の幅
--_has-hocus--false / --true子孫のリンクのホバーとフォーカス
--_has-focus-visible--false / --true子孫が:focus-visibleに一致しているか
--_has-value--false / --true検索の入力欄に文字があるか
--_is-cell--false / --true置く側から渡された「セルいっぱいに広げる」指示
--_is-full-bleed--false / --true置く側から渡された「画面の端まで広げる」指示
--site-header--is-sticky--false / --trueヘッダーを上端に固定するか
--color-scheme--current--light / --dark現在の配色

記事一覧とページの幅

記事一覧では年の一覧全体を包む要素が--_densityを宣言し、行、題、カテゴリ、番号、矢印の5つの要素がフラグを読みます。狭いときに隠す番号と矢印のdisplay: noneは--compactの側に書いています。広いときに表示を戻す宣言が要らないためです。

.article-archive {
	container: --scoped / inline-size;
}

._years {
	/* 条件が成り立たないときの見た目を基本にするため、初期値は狭いほうにする。広いときの差分だけを下の @container に書く */
	--_density: --compact;

	display: block grid;
	grid-template-columns: [--date] max-content [--category] 1fr;

	/*
	 * 閾値は、5 つの列が並べられる最小の幅。
	 * 内訳は、タイトル 240px、カテゴリ 160px、番号(2 桁)と日付(10 文字)と矢印(1em)の合計 112px。
	 * これに、列の間隔 32px を 4 つ分、行の左右の padding 12px を 2 つ分足す。
	 * 条件の中では var() が使えないので、間隔と padding は --spacing--lg と --spacing--xs の値を直接書く
	 */
	@container --scoped (calc((240 + 160 + 112) / 16 * 1rem + 32px * 4 + 12px * 2) <= inline-size) {
		--_density: --spacious;

		grid-template-columns:
			[--number] max-content [--date] max-content [--title] minmax(min(100%, calc(240 / 16 * 1rem)), 1fr)
			[--category] fit-content(calc(160 / 16 * 1rem)) [--arrow] max-content;
		column-gap: calc(var(--spacing--lg) * 1px);
	}
}

/* 狭いときは番号の列がないので、番号を隠す。フラグは子孫が自分で読むので、閾値を書き直さない */
._number {
	@container style(--_density: --compact) {
		display: none;
	}
}

記事のページの幅のフラグはサイズクエリではなく@mediaの中で切り替えています。ページの幅はいつもビューポートで決まるからです。フラグを立てる条件はサイズクエリでなくても構いません。

/* ページの段組みの幅は、いつもビューポートの幅で決まる。そのため、コンテナではなくメディアクエリで切り替える */
.page-article {
	/* 狭い画面を基本にして、広いときの差分だけを書くため、初期値は 1 列にする */
	--_density: --compact;

	/*
	 * 閾値は、2 列が並べられる最小の幅。
	 * 内訳は、目次の列の最小幅 224px、本文の列の最小幅 560px、列の間隔 64px、左右のページの余白の最大 64px を 2 つ分。
	 * 条件の中では var() が使えないので、間隔と余白は --spacing--xxl と --gutter--page の最大値を直接書く
	 */
	@media (calc((224 + 560) / 16 * 1rem + 64px + 64px * 2) <= width) {
		--_density: --spacious;
	}
}

置く側から渡すフラグ

記事の本文は--_is-full-bleed: var(--article-body--is-full-bleed, --false)と書いて、公開プロパティ--article-body--is-full-bleed(外側から値を渡すために公開しているカスタムプロパティ)を内部のフラグで受けています。記事のページが--article-body--is-full-bleed: --trueを渡すのは、1列の組み方のとき(--_density: --compactのとき)だけです。本文はそれを受けて、直下のコードブロックと実装例を画面の端まで広げます。幅のフラグを読んで別のフラグを渡す、2段の形です。記事のページが渡す側と、本文が受ける側のコードは次のとおりです。

._reading-area {
	& > ._body-outer {
		/*
		 * 1 列のときは、本文の列の左右がページの余白だけになる。
		 * そのため、コードブロックと実装例を画面の端まで広げてよいと、本文に伝える
		 */
		@container style(--_density: --compact) {
			--article-body--is-full-bleed: --true;
		}
	}
}
/* 抜き出し: 本文のコンポーネントのスタイルのうち、フラグを受ける部分と、フラグで切り替える部分だけ */
@scope (.wysiwyg) {
	:scope {
		/* 渡されなかったときの既定値(広げない)を 1 か所で決めるため、公開プロパティを直接読まずに内部のフラグで受ける */
		--_is-full-bleed: var(--article-body--is-full-bleed, --false);

		/*
		 * 1 行に入る字数を増やすため、本文の直下のコードブロックを画面の端まで広げる(実装例も同じ書き方で広げる)。
		 * 広げる量は、画面の幅と親の幅の差の半分ではなく、ガターの値で書く。
		 * セーフエリアのせいで左右のガターの幅が違うことがあり、差の半分では左右を別々に決められない
		 */
		& > .article-code-block {
			@container style(--_is-full-bleed: --true) {
				--article-code-block--is-full-bleed: --true;

				margin-inline: calc(var(--gutter--page-start) * -1) calc(var(--gutter--page-end) * -1);
			}
		}
	}
}

補足の注記の中に本文を入れ子にしたときは--article-body--is-full-bleed: --falseで打ち消しています。内側で初期値を書き直さないと、外側の--trueを受け継ぐためです。

._body {
	/*
	 * 補足の注記の中身も本文のコンポーネント(.wysiwyg)なので、外から渡された --true を引き継ぐ。
	 * そのままでは注記の中のコードブロックまで画面の端に広がるので、この要素で --false を渡して打ち消す
	 */
	--article-body--is-full-bleed: --false;
}

置く側が公開プロパティにフラグを渡し、置かれる側が内部のフラグで受ければ、クラスを足さずに外側から内側の見た目を切り替えられます。

サイト全体のフラグ

--site-header--is-stickyは:rootで--falseを宣言し、@media (height >= calc(600 / 16 * 1rem))の中で--trueにしています。ヘッダーの外枠をposition: stickyにするのは、@container style(--site-header--is-sticky: --true)のときだけです。:rootは自分のフラグを問い合わせられないので、:root自身のscroll-padding-block-startは同じメディアクエリの中に書いています。

:root {
	/* 高さが足りない画面でも本文を覆わないよう、初期値は固定しない側(--false)にする */
	--site-header--is-sticky: --false;

	/*
	 * 横向きのスマートフォンや拡大した画面では、固定したヘッダーが本文を覆うので、
	 * 高さが十分な画面(600px 相当以上)に限って固定する。
	 * 固定するときだけ、ページ内リンクの移動先やフォーカスした要素がヘッダーの下に隠れないよう、
	 * スクロールの余白をヘッダーの高さにする。高さはスクリプトが測って渡す
	 */
	@media (height >= calc(600 / 16 * 1rem)) {
		--site-header--is-sticky: --true;

		scroll-padding-block-start: var(--site-header--block-size, 4rlh);
	}
}
/*
 * sticky は親の範囲の中でしか動かない。ヘッダーの中身に指定すると、同じ高さの外枠が親になって留まらないので、
 * 外枠に指定して、ページ全体(.site-document)の範囲で留める
 */
._site-header-outer {
	position: relative;
	z-index: var(--z--header);

	@container style(--site-header--is-sticky: --true) {
		position: sticky;
		inset-block-start: 0;
	}
}

ビューポートの高さの条件はこの1か所にまとまっています。高さが足りない画面でヘッダーを固定しないのは、WCAG 2.1の達成基準1.4.10「リフロー」(レベルAA)に関わるためです。

達成基準 1.4.10 リフロー (レベル AA): コンテンツは、情報又は機能を損なうことなく、かつ、以下において 2 次元スクロールを必要とせずに提示できる:

  • 320 CSS ピクセルに相当する幅の縦スクロールのコンテンツ
  • 256 CSS ピクセルに相当する高さの横スクロールのコンテンツ

利用や意味の理解に 2 次元のレイアウトが必須である一部のコンテンツを除く。

達成基準 1.4.10: リフローを理解する | WCAG 2.1 解説書 より引用

この基準の主な目的は、ロービジョンの利用者がズーム機能でコンテンツを拡大したときに、縦と横の両方へスクロールせずに読めるようにすることです。基準を満たすための必須の条件ではありませんが、関連する参考の達成方法としてC34: 固定されていないスティッキーヘッダー/フッターに、メディアクエリーを利用する(新しいタブで開く)が用意されています。ズームしているときやスマートフォンを横向きにしているときは、上端に固定した要素が画面の大部分を覆ってコンテンツを読みにくくするおそれがあります。C34は、高さに余裕があるときだけヘッダーやフッターを固定するよう、メディアクエリで条件を付ける方法です。

このブログのヘッダーも同じ考え方で、ビューポートの高さが600px相当に満たないときは固定しません。デスクトップのブラウザでページを拡大すると、CSSピクセルで数えたビューポートの高さが小さくなり、閾値を下回ると固定が外れます。スマートフォンなどのピンチズームはレイアウトに使うビューポートを変えないので、この切り替えの対象になりません。

--color-scheme--currentはテーマ切替の選択とOSの設定から、:rootで--lightか--darkに決まります。色はトークンにlight-dark()で持たせていますが、合成の方法のような色以外の値はlight-dark()で切り替えられません。そこで、View Transitionsのmix-blend-modeを、ライトではdarken、@container style(--color-scheme--current: --dark)の中ではlightenにしています。このフラグはJavaScriptからもgetComputedStyle()で読め、Xの埋め込みの配色を決めるのに使っています。

/*
 * 配色のフラグ。色は light-dark() で切り替え、色以外を配色で変えたいときにだけ、このフラグを読む。
 * 配色は <meta name="color-scheme"> の content で決まる。テーマ切替は、この値を書き換える。
 */
:root {
	/* 明るい配色を基本にして、暗くなる条件だけを下に書く */
	--color-scheme--current: --light;

	/* content が「dark only」のときは、OS の設定に関係なく暗い配色 */
	&:has(meta[name='color-scheme'][content='dark only']) {
		/* 印刷では、テーマの設定に関係なく明るい配色にするので、暗くするのは印刷以外に限る */
		@media not print {
			--color-scheme--current: --dark;
		}
	}

	/* content が「light dark」のときは、OS の設定に従う */
	&:has(meta[name='color-scheme'][content='light dark']) {
		@media not print {
			@media (prefers-color-scheme: dark) {
				--color-scheme--current: --dark;
			}
		}
	}
}
/*
 * 選択中のカテゴリを示す背景は、ページを移るとき、前の位置から次の位置へ動く。
 * 動いている間の背景は、通り道の項目の文字と印の手前に描かれて、それらを隠す。
 * そこで、ページの画像と比べて暗いほうの色を残して重ね、文字と印を透かす。ダークでは、明るいほうの色を残す。
 * 変えるのは色ではなく合成の方法なので、light-dark() ではなくフラグで切り替える。
 * 背景に名前を付けるのは動きを減らさない設定のときだけなので、この指定も同じ条件の中に書く
 */
::view-transition-group(--category-navigation--current) {
	@media (prefers-reduced-motion: no-preference) {
		mix-blend-mode: darken;

		@container style(--color-scheme--current: --dark) {
			mix-blend-mode: lighten;
		}
	}
}

ブレイクポイントの共有

ヘッダーの固定と同じ形で、サイト全体のブレイクポイントもフラグで管理できます。:rootでメディアクエリの結果をフラグに置き換えておけば、各コンポーネントは閾値を書き写さずに@container style()でフラグの値を読むだけで済みます。閾値を変えるときに直すのは:rootの1か所です。

CSSだけではブレイクポイントを変数管理できないというのは嘘で、スタイルクエリを経由すればCSSのみでもブレイクポイントを変数管理できます。 #Web制作 pic.twitter.com/jBocWYKezi

メディアクエリの条件に名前を付ける仕組みとしては@custom-mediaアットルールも登場しています。--で始まる名前で条件を定義しておき、@mediaから名前で参照するものです。2026年11月時点では、主要なブラウザの標準の設定では使えません。

@custom-media

一部のブラウザのみ

執筆時点(2026年11月)から変わっていません

未対応
未対応
未対応
未対応
未対応

web-features 3.44.0 のデータ

webstatus.dev で見る

ただし、ブレイクポイントは端末の幅に合わせて決めるのではなく、できるだけ、コンテンツが崩れる幅で定義するべきです。そのため、サイト全体で共有するのはページの段組みのようにビューポートの幅で決まるものに限ります。コンポーネントの中の切り替えは、記事一覧の例のように、そのコンポーネントの中身が収まらなくなる幅を閾値にしています。

1つのサイズクエリにまとめる方法との比較

閾値を1か所にまとめるだけなら、スタイルクエリを使わなくてもできます。コンポーネントの末尾にサイズクエリを1つ置き、広い幅で変わる子のルールをその中に集める方法です。

/* 1 つのサイズクエリに子のルールをまとめる(フラグを使わない書き方) */
@layer components {
	@scope (.scoped.article-card) to (.scoped) {
		:scope {
			container: --scoped / inline-size;
		}

		._layout {
			display: block grid;
		}

		._thumbnail {
			aspect-ratio: 16 / 9;
		}

		._title {
			font-size: var(--text--subheading--fluid-font-size);
		}

		/*
		 * 広い幅の見た目は末尾のクエリに集める。これより前に書くと、同じ詳細度では上書きできない。
		 * 閾値: サムネイルを横に並べたときに、本文の列に 144px 相当が残るコンテナの幅。
		 * 内訳は本文の列(rem なので文字の大きさに追従)、サムネイル 96px、
		 * 横並びのときの gap 16px、左右の padding 16px × 2。
		 * gap と padding の宣言は、この例では省いている
		 */
		@container --scoped (calc(144 / 16 * 1rem + 96px + 16px + 32px) <= inline-size) {
			._layout {
				grid-template-columns: 96px 1fr;
			}

			._thumbnail {
				aspect-ratio: 1;
			}

			._title {
				font-size: var(--text--heading--fluid-font-size);
			}
		}
	}
}

この方法でも閾値は1か所です。スタイルクエリに対応していない環境でも同じ表示になり、覚える概念も増えません。

それでもフラグの書き方を選ぶ理由は2つあります。

  • 1つの要素の見た目が、要素のルールと末尾のクエリの2か所に分かれない
  • 要素のルールを並べ替えても、通常の宣言と条件付きの宣言が一緒に動く。末尾のクエリにまとめる方法では、同じ詳細度なら後に書いたほうが勝つので、クエリのブロックを元のルールより前に置くと切り替わらなくなる。フラグでも宣言の順の影響はなくならず、各ルールの中で条件付きの宣言を通常の宣言より後に書く必要がある

代わりに、覚える概念が3つ増えます。カスタムプロパティを条件として使うこと、フラグを立てた要素自身と子孫とで見た目を書く場所が分かれること、フラグの名前と値の組を決めておくことです。

私は、状態ごとの見た目を1つのルールで確かめられることを優先して、フラグの書き方を選んでいます。状態を決める条件はフラグを立てる側の1か所に集まり、各要素のルールには状態ごとの見た目だけが残ります。いつその状態になるか、どこから値を受け継ぐかを確かめるにはフラグを立てる側も読むことになり、これがこの書き方の負担です。

使うのは、同じ閾値で変わる子要素が2つ以上あるときです。変わる子要素が1つだけなら、その要素にサイズクエリを直接書けば足ります。

フラグを使うときの注意

意味を持つ状態はHTMLの属性で表す

フラグは見た目のためのもので、状態を支援技術に伝えません。開閉や選択のように意味を持つ状態はaria-expandedやaria-selectedでHTMLに表し、そちらを正とします。CSSのフラグだけで管理すると、見た目は変わっても支援技術には状態が伝わりません。子孫にまとめて伝えたいときは、その属性を読んで見た目用のフラグを立てます。

フラグの調べ方

フラグを読んでいるルールはstyle(--_densityで、宣言は--_density:で検索できます。同じ名前のフラグを複数のコンポーネントで使っているので、検索はそのコンポーネントのファイルに絞ります。

開発者ツールで子孫を選ぶと、当たっている@container style(…)が条件とともに表示され、フラグは祖先から継承した値として見えます。ホバーの見た目は要素の状態を:hoverに固定して調べます。

対応状況と対応していない環境での表示

カスタムプロパティの一致を問うスタイルクエリは、Chrome 111、Edge 111、Safari 18、Firefox 151で使えます。Baselineでは、2026年5月19日から「新しく利用可能」になっています。

コンテナスタイルクエリ

新しく利用可能 2026年5月から

執筆時点(2026年11月)から変わっていません

111
111
151
18
18

web-features 3.44.0 のデータ

webstatus.dev で見る

対応していない環境では@container style()の中が当たらず、初期値の見た目だけが残ります。子孫の初期値を狭い幅に合わせて書いておけば、子孫は狭い幅の見た目で表示されます。記事カードの例では、サイズクエリを直接書いた._layoutの横並びだけが広い幅で切り替わり、サムネイルの比率と題の文字サイズは狭い幅の指定のまま残ります。このブログが対象にしているブラウザ(Safariは26以降)はどれもスタイルクエリに対応しているので、フォールバックを書かずに使っています。

幅、ホバー、置き場所、ビューポートの高さ、配色と、条件の出どころは違っても、1か所でフラグを立てて、読む側は@container style()で値の一致を見るという同じ形で書けます。同じ閾値のサイズクエリを2つ以上の子要素に書き写していたら、フラグに置き換えられないか確かめてみてください。