CSSの進化:@mediaルールの強化で可能になる新しいデザイン
近年 @media で本当に変わったのは、比較演算子で範囲を書ける「範囲構文」(2023年3月にBaseline入り)と、scripting(2023年12月)の2つです。加えて、要素幅で分岐する コンテナクエリ @container(2023年2月)が、@media の限界を埋めるものとして加わりました。
この記事は2024年の公開後、2026年9月に見直しました。 公開当時に「導入された」と書いていた light-level メディア特性は、どのブラウザエンジンにも実装されず、仕様からも削除されています。この点を訂正したうえで、実際に使える機能に差し替えました。
@media (light-level: dim)は動作しません。 Media Queries Level 5 の草案に一時期あっただけで実装例が無く、prefers-contrastやprefers-color-schemeと役割が重複するとして仕様から外れましたprefers-reduced-motion(2019年)、prefers-color-scheme(2019年)、forced-colors(2020年)は2024年の新機能ではありません
いつから使えるのかを整理する
| 機能 | 使えるようになった時期 | 位置づけ |
|---|---|---|
prefers-color-scheme |
2019年 | 枯れた機能 |
prefers-reduced-motion |
2019年 | 枯れた機能 |
forced-colors |
2020年 | 枯れた機能 |
コンテナクエリ @container |
2023年2月 Baseline | 新しい |
範囲構文 (400px <= width) |
2023年3月 Baseline | 新しい |
scripting |
2023年12月 | 新しい |
prefers-reduced-transparency |
Chrome 118〜(Level 5草案) | 実験的 |
light-level |
実装されず、仕様から削除 | 使えない |
範囲構文:min-width / max-width を書かなくてよくなった
比較演算子でそのまま範囲を書けます。 2023年3月にSafari 16.4が対応して全ブラウザで使えるようになりました。
/* 従来 */
@media (min-width: 768px) { ... }
@media (max-width: 767.98px) { ... }
@media (min-width: 768px) and (max-width: 1023.98px) { ... }
/* 範囲構文 */
@media (width >= 768px) { ... }
@media (width < 768px) { ... }
@media (768px <= width < 1024px) { ... }
最大の利点は「1px問題」が消えることです。 従来の max-width は指定値を含むため、min-width: 768px と max-width: 768px を並べると 768px ちょうどで両方が適用されます。これを避けるために 767.98px のような値を書く慣習がありましたが、範囲構文なら width < 768px と書くだけで境界が明確になります。
/* 768px ちょうどで両方適用されてしまう */
@media (max-width: 768px) { .box { color: red; } }
@media (min-width: 768px) { .box { color: blue; } }
/* 重ならない */
@media (width < 768px) { .box { color: red; } }
@media (width >= 768px) { .box { color: blue; } }
height や resolution にも使えます。
@media (height < 600px) { ... }
@media (resolution >= 2x) { ... }
scripting:JavaScriptが動くかどうかで分岐する
<noscript> のCSS版です。JavaScriptが無効、または読み込みに失敗した環境向けのスタイルを書けます。
/* JavaScript が動く環境 */
@media (scripting: enabled) {
.fade-in { opacity: 0; transition: opacity .4s; }
.fade-in.is-visible { opacity: 1; }
}
/* JavaScript が無い環境では最初から見えている */
@media (scripting: none) {
.js-only-button { display: none; }
.no-js-message { display: block; }
}
これは実害のあるバグを防ぎます。 スクロールアニメーションで初期状態を opacity: 0 にしていると、JavaScriptが失敗したときに本文が読めなくなります。scripting: enabled の中に閉じ込めておけば、その事故が起きません。
| 値 | 意味 |
|---|---|
enabled |
スクリプトが使える |
none |
スクリプトが使えない |
initial-only |
読み込み時のみ動く(実装例はほぼ無い) |
関連する実装はIntersection Observer APIを使ったスクロールアニメーションにまとめています。
コンテナクエリ:画面幅ではなく親要素の幅で分岐する
@media の根本的な限界は「ビューポートの幅しか見られない」ことです。 同じカードコンポーネントを、広いメイン領域と狭いサイドバーの両方に置く場合、画面幅では判断できません。
/* 親側でコンテナを宣言する */
.card-container {
container-type: inline-size;
container-name: card;
}
/* 親の幅が 400px 以上なら横並びにする */
@container card (width >= 400px) {
.card {
display: grid;
grid-template-columns: 120px 1fr;
gap: 16px;
}
}
これで同じCSSのまま、置かれた場所に応じてレイアウトが変わるコンポーネントになります。サイドバーに置けば縦積み、メイン領域に置けば横並びです。
注意点が2つあります。
container-type: inline-sizeを指定した要素は、その方向のサイズが子要素の内容に依存しなくなります。高さが潰れる場合はcontainer-type: sizeではなくinline-sizeを使っているか確認してください- 自分自身にはクエリを掛けられません。必ず親要素にコンテナを宣言し、子要素にスタイルを当てます
コンテナ単位も使えます。
@container card (width >= 400px) {
.card__title {
font-size: 5cqi; /* コンテナのインライン方向サイズの5% */
}
}
@media |
@container |
|
|---|---|---|
| 基準 | ビューポート | 親要素 |
| 向く用途 | ページ全体のレイアウト | 再利用するコンポーネント |
| ユーザー設定の取得 | できる | できない |
ページの骨格は @media、部品は @containerという分担が現実的です。
prefers-reduced-motion:正しい書き方
2019年からある機能ですが、書き方を間違えると効きません。よく見かける次のコードには2つ問題があります。
/* 効かないことがある */
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0s; }
}
- 詳細度で負ける:後から書かれた個別セレクターに上書きされます
transitionが止まらない:animation-durationだけではトランジションは残ります
実務では次の形が確実です。
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
0s ではなく 0.01ms にしているのは意図的です。 完全に0にすると animationend や transitionend イベントが発火しないことがあり、「アニメーション完了後に要素を消す」といった処理が止まります。ごく短い時間を残すことで、見た目は瞬時のまま処理の流れを保てます。
JavaScript側での判定はWeb Animations APIのanimateメソッドにまとめています。
forced-colors:色を指定してはいけない
ここは誤解が多い機能です。 Windowsのハイコントラストモードなど、OSが色を強制する状態を検出します。
やってはいけないのは、この中で色を指定し直すことです。
/* ユーザーが選んだ配色を上書きしてしまう */
@media (forced-colors: active) {
body {
background-color: black;
color: yellow;
}
}
強制カラーモードでは、ユーザーが自分に見やすい配色を選んでいます。著者が黒と黄色を指定するのは、その選択を無視することになります。そもそもこのモードでは著者の色指定の多くが上書きされるため、意図通りにもなりません。
正しい使い方は、色が失われることで壊れる部分を補うことです。
@media (forced-colors: active) {
/* 背景色で状態を示していたボタンに、輪郭を足す */
.button--primary {
border: 2px solid ButtonText;
}
/* 背景画像で描いていたアイコンは消えるので、代替を出す */
.icon-search::after {
content: "検索";
}
/* システムカラーキーワードを使う */
.card {
border: 1px solid CanvasText;
background: Canvas;
color: CanvasText;
}
}
使えるシステムカラーキーワードには Canvas(背景)、CanvasText(文字)、LinkText、ButtonFace、ButtonText、Highlight などがあります。具体的な色ではなく「役割」で指定するのがポイントです。
ロゴなど、色を保持しなければ意味が通らない要素には forced-color-adjust: none を使います。ただし乱用は禁物で、本当に必要な箇所だけに限定してください。
prefers-color-scheme と light-dark()
ダークモード対応は prefers-color-scheme が基本ですが、色ごとに light-dark() 関数で書く方法も使えるようになりました。
:root {
/* 両方のスキームに対応することを宣言する */
color-scheme: light dark;
--bg: light-dark(#ffffff, #121212);
--text: light-dark(#333333, #f2f2f2);
--link: light-dark(#0b5cff, #8ab4ff);
}
body {
background: var(--bg);
color: var(--text);
}
color-scheme: light dark; の宣言が必須です。 これが無いと light-dark() は常にライト側の値を返します。あわせて、この宣言はフォーム部品やスクロールバーの見た目もOS側のダークテーマに合わせてくれます。
手動でのテーマ切り替えを併用する場合は、@media と [data-theme] の両方を書きます。
:root { --bg: #ffffff; --text: #333333; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --bg: #121212; --text: #f2f2f2; }
}
:root[data-theme="dark"] { --bg: #121212; --text: #f2f2f2; }
色の設計自体には oklch() が便利です。詳しくはCSS color()関数の使い方と実用例にまとめました。
知っておくと役立つメディア特性
| 特性 | 用途 |
|---|---|
(hover: hover) |
ホバーできる環境だけにホバー効果を付ける |
(pointer: coarse) |
指で操作する環境。タップ領域を広げる |
(prefers-contrast: more) |
高コントラストを希望している |
(prefers-reduced-transparency: reduce) |
半透明を減らしたい(Chrome 118〜) |
(update: slow) |
電子ペーパーなど。アニメーションを止める |
(display-mode: standalone) |
PWAとしてインストールされた状態 |
特に (hover: hover) は実用性が高いです。タッチデバイスでホバー効果を書くと、タップ後にスタイルが残り続けるという定番の不具合を防げます。
@media (hover: hover) and (pointer: fine) {
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgb(0 0 0 / .12);
}
}
まとめ
light-levelは実装されず仕様からも削除されました。 使えませんprefers-color-scheme/prefers-reduced-motion/forced-colorsは2019〜2020年からの機能- 範囲構文
(768px <= width < 1024px)で「1px問題」が消える scripting: enabledでJavaScript失敗時にコンテンツが消える事故を防げる- コンポーネント単位の分岐は
@mediaではなく@container prefers-reduced-motionは!important付きでtransitionも止める。0sではなく0.01msforced-colorsの中で色を指定しない。 輪郭やテキスト代替を足す方向で対応するlight-dark()を使うならcolor-scheme: light dark;の宣言が必須
メディアクエリの記事は情報が古いまま残りやすい分野です。「その特性を実装しているブラウザが実在するか」を Can I use などで確認してから使ってください。