ダークモード戦略
問題
AIエージェントはダークモードを実装する際、スタイルシート全体を複製したり、JavaScriptを使ってすべての色宣言をオーバーライドするクラスを切り替えたりすることが多いです。結果として、冗長で壊れやすく、保守が難しいコードになります。よくあるミスとして、色を素朴に反転させる(白が黒になり、ブランドカラーはそのまま)、テキストとサーフェスの知覚的な明るさを調整しない、ダークモードで眼精疲労を引き起こすきついコントラストを作るなどがあります。
解決方法
モダンCSSはダークモードに対して階層的なアプローチを提供します:
color-scheme— ブラウザにUA スタイル要素(フォームコントロール、スクロールバー)をライトまたはダークに調整するよう指示するprefers-color-scheme— ユーザーのOS レベルのテーマ設定を検出するメディアクエリlight-dark()— アクティブなカラースキームに応じて2つの色値のいずれかを返すCSS関数(Baseline 2024)- CSSカスタムプロパティ — テーマ設定の基盤で、1セットのプロパティ宣言でカラートークンを切り替えられる
コード例
color-scheme: ブラウザダークモードへのオプトイン
/* Tell the browser this page supports both light and dark */
:root {
color-scheme: light dark;
}
この1行で、フォームコントロール、スクロールバー、その他のブラウザスタイル要素が自動的に適応します。これがないと、<input>、<select>、<textarea> はページの背景がダークでもライトテーマのままです。
prefers-color-scheme メディアクエリ
:root {
--color-bg: oklch(99% 0.005 264);
--color-surface: oklch(97% 0.01 264);
--color-text: oklch(20% 0.02 264);
--color-text-muted: oklch(40% 0.02 264);
--color-border: oklch(85% 0.01 264);
--color-primary: oklch(55% 0.22 264);
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: oklch(15% 0.01 264);
--color-surface: oklch(20% 0.015 264);
--color-text: oklch(90% 0.01 264);
--color-text-muted: oklch(65% 0.01 264);
--color-border: oklch(30% 0.015 264);
--color-primary: oklch(70% 0.18 264); /* Lighter primary for dark bg */
}
}
light-dark() 関数
light-dark() は、1つの宣言に両方の色値をインラインで記述することでダークモードを簡素化します。color-scheme の設定が必要です。
:root {
color-scheme: light dark;
--color-bg: light-dark(oklch(99% 0.005 264), oklch(15% 0.01 264));
--color-surface: light-dark(oklch(97% 0.01 264), oklch(20% 0.015 264));
--color-text: light-dark(oklch(20% 0.02 264), oklch(90% 0.01 264));
--color-text-muted: light-dark(oklch(40% 0.02 264), oklch(65% 0.01 264));
--color-border: light-dark(oklch(85% 0.01 264), oklch(30% 0.015 264));
--color-primary: light-dark(oklch(55% 0.22 264), oklch(70% 0.18 264));
}
最初の引数がライトモードで使われ、2番目がダークモードで使われます。メディアクエリは不要です。
ライト vs ダークテーマ — 横並び比較
JavaScript テーマトグル
ユーザー制御のテーマ切り替え(OS設定のオーバーライド):
<button id="theme-toggle" aria-label="Toggle theme">Toggle theme</button>
:root {
color-scheme: light dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
/* Custom properties using light-dark() respond to color-scheme */
:root {
--color-bg: light-dark(oklch(99% 0.005 264), oklch(15% 0.01 264));
--color-text: light-dark(oklch(20% 0.02 264), oklch(90% 0.01 264));
}
<script>
const toggle = document.getElementById("theme-toggle");
const root = document.documentElement;
// Check for saved preference, fallback to OS preference
const saved = localStorage.getItem("theme");
if (saved) {
root.dataset.theme = saved;
}
toggle.addEventListener("click", () => {
const current = root.dataset.theme;
const next =
current === "dark"
? "light"
: current === "light"
? "dark"
: window.matchMedia("(prefers-color-scheme: dark)").matches
? "light"
: "dark";
root.dataset.theme = next;
localStorage.setItem("theme", next);
});
</script>
完全なダークモードトークンシステム
:root {
color-scheme: light dark;
/* Surfaces */
--surface-0: light-dark(oklch(100% 0 0), oklch(13% 0.01 264));
--surface-1: light-dark(oklch(97% 0.005 264), oklch(18% 0.012 264));
--surface-2: light-dark(oklch(94% 0.008 264), oklch(22% 0.015 264));
--surface-3: light-dark(oklch(90% 0.01 264), oklch(27% 0.018 264));
/* Text */
--text-primary: light-dark(oklch(20% 0.02 264), oklch(92% 0.01 264));
--text-secondary: light-dark(oklch(40% 0.015 264), oklch(70% 0.01 264));
--text-disabled: light-dark(oklch(60% 0.01 264), oklch(45% 0.01 264));
/* Borders */
--border-default: light-dark(oklch(85% 0.01 264), oklch(30% 0.015 264));
--border-strong: light-dark(oklch(70% 0.015 264), oklch(45% 0.02 264));
/* Brand */
--brand: light-dark(oklch(55% 0.22 264), oklch(72% 0.17 264));
--brand-hover: light-dark(oklch(48% 0.22 264), oklch(78% 0.15 264));
/* Feedback */
--success: light-dark(oklch(48% 0.15 145), oklch(70% 0.15 145));
--warning: light-dark(oklch(58% 0.18 85), oklch(75% 0.15 85));
--danger: light-dark(oklch(52% 0.2 25), oklch(70% 0.18 25));
}
ダークモードでの画像とメディア
/* Reduce brightness and increase contrast for images in dark mode */
@media (prefers-color-scheme: dark) {
img:not([src*=".svg"]) {
filter: brightness(0.9) contrast(1.05);
}
/* Invert dark-on-light diagrams and illustrations */
img.invertible {
filter: invert(1) hue-rotate(180deg);
}
}
誤ったテーマのフラッシュ(FOWT)の防止
<head>
<!-- Inline script to apply theme before any render -->
<script>
(function () {
const saved = localStorage.getItem("theme");
if (saved) {
document.documentElement.dataset.theme = saved;
}
})();
</script>
</head>
AIがよくやるミス
:rootにcolor-scheme: light darkを設定せず、ページがダークでもフォームコントロールやスクロールバーがライトモードのままになる- CSSカスタムプロパティを切り替える代わりに、ダークモード用にスタイルシート全体を複製している
color-schemeを宣言せずにlight-dark()を使っている —color-schemeが設定されていないと関数はデフォルトで最初の(ライト)値を返す- 明度レベルを調整する代わりに色を素朴に反転させている(
white↔black)— ダークモードの背景はダークグレー(純粋な黒ではない)で、テキストはオフホワイト(純粋な白ではない)であるべき - 両方のモードで同じブランドカラーを維持している — ダーク背景上の彩度の高い色は過度に鮮やかに見えるため、彩度を下げ明度を上げる必要がある
- ダークモードでフォントウェイトを減らしていない — ダーク背景上のテキストは知覚的に太く見えるため、
font-weightを30〜50単位減らすと読みやすさが向上する - ページ全体に
filter: invert(1)を適用して「ダークモード」にしている — これは画像、動画、意図的な色を持つすべての要素を壊す localStorageの代わりにJavaScript のステートにテーマ設定を保存し、ページリロード時に誤ったテーマがフラッシュする:rootのカスタムプロパティを活用する代わりに、JavaScriptで個々の要素の.dark-modeクラスを切り替えている
使い分け
prefers-color-scheme
- 手動トグルなしでOS設定を尊重する最もシンプルなアプローチ
- 静的サイト、ブログ、ドキュメント
light-dark()
- 可読性のために両方の色値を同じ宣言に並置したい場合
color-schemeを使って(:rootまたは特定の要素で)モードを制御する場合
カスタムプロパティ + data 属性
- ユーザーが手動テーマトグルを必要とする場合
- アプリが2つ以上のテーマをサポートする場合(ライト、ダーク、ハイコントラストなど)
- SPA や Web アプリケーション
- これらのトークンをパレット、テーマ、コンポーネントの各レイヤーに整理する方法については、Three-Tier Color Strategy を参照してください
color-scheme のみ
- カスタムカラー変更なしで、ブラウザネイティブ要素のテーマ設定(フォーム、スクロールバー)のみが必要なページ
Tailwind CSS
Tailwind の dark: バリアントにより、ダークモードのスタイリングが簡単になります。class ストラテジーでは、親要素に dark クラスを追加すると、その中のすべての dark: ユーティリティがアクティブになります。
Tailwind: dark: バリアント — 横並び