この記事の対象: 既存サイトにダークモードを後付けしたい制作者
読了時間: 約12分
ダークモード対応は「色を反転させれば終わり」ではありません。OSの設定に追従させるだけなら CSS 数行で済みますが、ユーザーが手で切り替えられるようにした瞬間、状態の保存・ちらつき防止・画像の差し替えという3つの問題が同時に出てきます。
この記事では、素の HTML / CSS / JavaScript だけで、OS追従と手動切り替えの両方に対応した実装を組み立てます。ライブラリは使いません。コードはすべてそのままコピーして動く形で載せ、変更すべき箇所を明示します。作業しながら読めば、今日のうちに切り替えボタンまで動く状態になります。
前提を確認する

動作環境と対応ブラウザ
この記事のコードが必要とする環境は次のとおりです。
| 項目 | 条件 |
|---|---|
| CSS カスタムプロパティ(変数) | 全モダンブラウザで利用可。IE11 は非対応 |
prefers-color-scheme |
Chrome 76以降 / Safari 12.1以降 / Firefox 67以降 |
color-scheme プロパティ |
Chrome 81以降 / Safari 13以降 / Firefox 96以降 |
localStorage |
全モダンブラウザ。プライベートブラウズで制限される場合あり |
<picture> 要素 |
全モダンブラウザ |
IE11 を対象に含む案件では、カスタムプロパティが効かないため、この方式は使えません。その場合はダークモードを提供せずライトモード固定にするのが無難です。
対応状況を自分で確認したいときは、ブラウザの開発者ツールのコンソールで次を実行します。
console.log(window.matchMedia('(prefers-color-scheme: dark)').media);
(prefers-color-scheme: dark) がそのまま返れば対応済み、not all が返れば非対応です。
触るファイル
CMS を使わない静的サイトを想定します。WordPress や Shopify に載せる場合の置き場所は後半で触れます。
/assets/css/style.css— 色の定義と全スタイル/assets/js/theme.js— 切り替えロジック(<head>にインラインで置く部分は別)- 各 HTML の
<head>
作業の全体像
1工程目をどれだけ丁寧にやるかで、残り3つの手間が決まります。色がCSS全体に散らばったまま2工程目に進むと、prefers-color-scheme の中に同じ量のCSSを書き足すことになります。
色をCSS変数として設計する

まず変数に出す
ダークモード対応で最初にやるのは、色の書き換えではなく色の棚卸しです。既存CSSから色指定を抜き出し、:root に集約します。
/* /assets/css/style.css の先頭に置く */
:root {
/* 面 */
--color-bg: #ffffff;
--color-surface: #f6f6f6;
--color-surface-raised: #ffffff;
/* 文字 */
--color-text: #1a1a1a;
--color-text-muted: #666666;
--color-text-inverse: #ffffff;
/* 線 */
--color-border: #e0e0e0;
--color-border-strong: #c4c4c4;
/* ブランド・アクション */
--color-accent: #0b62d6;
--color-accent-hover: #0a52b4;
--color-accent-text: #ffffff;
/* 状態 */
--color-danger: #c0392b;
--color-success: #1e7e4e;
/* 影 */
--shadow-card: 0 1px 3px rgba(0, 0, 0, .12);
}
変更する箇所: 各色の値を自サイトの配色に置き換えます。変数名は変えないほうが以降のコードをそのまま使えます。
ここで重要なのは、変数名を 役割で付ける ことです。--color-white や --color-gray-100 のように色そのもので名付けると、ダークモードで「白という名前の変数に黒が入る」という破綻が起きます。--color-bg(背景)、--color-text(本文)のように、どこに使うかで命名します。
何個に絞るか
変数は多すぎても運用できません。中規模のコーポレートサイトなら、面3つ・文字3つ・線2つ・アクセント3つ・状態2つ程度、合計13〜15個が扱いやすい範囲です。ページごとに色が増えていくなら、それは設計の問題であって変数の問題ではありません。
役割で名付ける(推奨)
- –color-bg / –color-text
- ダーク側で値だけ入れ替わる
- 使う場所が名前から分かる
- 配色変更が1か所で済む
見た目で名付ける
- –color-white / –gray-100
- ダーク側で名前と中身が矛盾
- どこに使う変数か分からない
- 結局ハードコードに戻る
役割ベースの命名にしておくと、ダークモード側では「値を差し替えるだけ」で済みます。ここを外観ベースにすると、あとで必ず書き直しになります。
既存CSSの置き換え
色指定を変数参照に書き換えます。
body {
background: var(--color-bg);
color: var(--color-text);
}
.card {
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-card);
}
.card__meta {
color: var(--color-text-muted);
}
.button {
background: var(--color-accent);
color: var(--color-accent-text);
border: none;
}
.button:hover {
background: var(--color-accent-hover);
}
変更する箇所: .card .button などのクラス名は自サイトのものに合わせます。
置き換え漏れを探すには、エディタで # と rgb( を正規表現検索します。VS Code なら検索欄に正規表現モードで次を入れます。
(color|background|border|fill|stroke)[^;{]*:\s*(#|rgb|hsl)
:root 内のヒットは正解なので、それ以外が残っていれば置き換え対象です。
prefers-color-scheme でOSの設定に追従させる

最小の実装
OS追従だけでよければ、メディアクエリで変数を上書きするだけです。
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #15171a;
--color-surface: #1c1f23;
--color-surface-raised: #23272c;
--color-text: #e8eaed;
--color-text-muted: #9aa0a6;
--color-text-inverse: #15171a;
--color-border: #32363b;
--color-border-strong: #4a4f55;
--color-accent: #6aa9f5;
--color-accent-hover: #8dbdf8;
--color-accent-text: #0d1117;
--color-danger: #f07168;
--color-success: #5cc98d;
--shadow-card: 0 1px 3px rgba(0, 0, 0, .5);
}
}
変更する箇所: 各値を自サイトのダーク配色に置き換えます。変数名はライト側と1対1で対応させ、片方だけ増やしたり減らしたりしないこと。
ダーク側の値を決める3つのルール
ライト側の色をそのまま反転させると、たいてい見づらくなります。実装上、次の3点を押さえると破綻しません。
純黒と純白を使わない。 背景に #000000、文字に #ffffff を置くとコントラストが強すぎて、長文でちらつきを感じます。背景は #15171a 前後、文字は #e8eaed 前後に寄せます。
彩度の高い色は明度を上げて彩度を下げる。 ライト側の #0b62d6 のような濃い青を暗い背景に置くと沈んで読めません。ダーク側では明るく、かつ少し淡くします。上のコードで #6aa9f5 にしているのはこの調整です。
影ではなく明度差で階層を作る。 暗い背景の上では影がほぼ見えません。カードを浮かせたいときは、背景より明るい面色(--color-surface-raised)を使います。
| 用途 | ライト | ダーク | 考え方 |
|---|---|---|---|
| ページ背景 | 最も明るい | 最も暗い | 純黒を避ける |
| 浮いた面 | 白か背景と同じ | 背景より明るい | 明度の向きが逆転する |
| 本文 | 濃いグレー | 明るいグレー | 純白を避ける |
| 補助文字 | 背景寄りのグレー | 本文より暗いグレー | コントラスト比3:1以上を確保 |
| アクセント | 濃いめ | 明るめ・やや淡め | 彩度を下げる |
コントラスト比は開発者ツールで確認できます。Chrome なら要素を選択して Styles パネルの色見本をクリックすると、背景との比率が表示されます。本文テキストは 4.5:1 以上、大きな文字やUI部品の境界は 3:1 以上が基準です。
color-scheme を必ず書く
変数の上書きだけでは、フォームのセレクトボックス、スクロールバー、<input> のデフォルト表示などブラウザ描画の部品が明るいままです。これを直すのが color-scheme プロパティです。
:root {
color-scheme: light dark;
}
この1行を :root に入れておくだけ で、ブラウザ標準UIがOSの設定に追従します。手動切り替えを実装する場合は後述の形に書き換えますが、OS追従だけなら上の1行で完了します。
手動切り替えを実装する

設計:3つの状態を持たせる
手動切り替えを付けるとき、状態は「ライト」「ダーク」の2つではなく、「OSに従う」を含めた3つにします。ユーザーが一度ダークを選んだあと、OSの設定に戻す手段がないのは不便です。
CSSの優先順位は次のように組みます。
1. data-theme 属性がある
- light → ライト固定
- dark → ダーク固定
2. 属性がない(= auto)
- prefers-color-scheme に従う
保存値が auto のときは <html> から属性を外す、という単純な作りにすると、CSS側で「autoのときの分岐」を書かずに済みます。
CSS:属性セレクタで上書きする
先ほどの CSS を、属性による上書きに対応した形に書き換えます。
/* /assets/css/style.css */
/* 1. 既定値(ライト) */
:root {
color-scheme: light;
--color-bg: #ffffff;
--color-surface: #f6f6f6;
--color-surface-raised: #ffffff;
--color-text: #1a1a1a;
--color-text-muted: #666666;
--color-text-inverse: #ffffff;
--color-border: #e0e0e0;
--color-border-strong: #c4c4c4;
--color-accent: #0b62d6;
--color-accent-hover: #0a52b4;
--color-accent-text: #ffffff;
--color-danger: #c0392b;
--color-success: #1e7e4e;
--shadow-card: 0 1px 3px rgba(0, 0, 0, .12);
}
/* 2. OS がダーク かつ 明示指定がないとき */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
color-scheme: dark;
--color-bg: #15171a;
--color-surface: #1c1f23;
--color-surface-raised: #23272c;
--color-text: #e8eaed;
--color-text-muted: #9aa0a6;
--color-text-inverse: #15171a;
--color-border: #32363b;
--color-border-strong: #4a4f55;
--color-accent: #6aa9f5;
--color-accent-hover: #8dbdf8;
--color-accent-text: #0d1117;
--color-danger: #f07168;
--color-success: #5cc98d;
--shadow-card: 0 1px 3px rgba(0, 0, 0, .5);
}
}
/* 3. ユーザーがダークを選んだとき(OS設定に関係なく) */
:root[data-theme="dark"] {
color-scheme: dark;
--color-bg: #15171a;
--color-surface: #1c1f23;
--color-surface-raised: #23272c;
--color-text: #e8eaed;
--color-text-muted: #9aa0a6;
--color-text-inverse: #15171a;
--color-border: #32363b;
--color-border-strong: #4a4f55;
--color-accent: #6aa9f5;
--color-accent-hover: #8dbdf8;
--color-accent-text: #0d1117;
--color-danger: #f07168;
--color-success: #5cc98d;
--shadow-card: 0 1px 3px rgba(0, 0, 0, .5);
}
/* 4. ユーザーがライトを選んだとき(OSがダークでも) */
:root[data-theme="light"] {
color-scheme: light;
}
ダーク側の値が2回出てくるのが気になるところです。Sass を使っているなら @mixin dark-tokens にまとめて2か所から @include すれば重複が消えます。素のCSSで重複を避けたい場合は、次の書き方も使えます。
/* 重複を避ける書き方:セレクタをカンマで並べる */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--color-bg: #15171a;
/* 以下ダーク値 */
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--color-bg: #15171a;
/* 以下ダーク値 */
}
セレクタをカンマで1つにまとめたくなりますが、メディアクエリの内と外はまとめられません。CSSの構文上、@media ブロックの中と外のルールを1つのセレクタリストで書くことはできないためです。重複を1回にしたいならビルドツールを挟むしかありません。
JavaScript:ちらつきを防ぐ読み込み位置
ここが一番つまずく場所です。</body> 直前の <script> でテーマを適用すると、CSSが描画されたあとに属性が付く ので、ダークを選んでいるユーザーに一瞬白い画面が見えます。フラッシュ・オブ・アンスタイルド・コンテンツと呼ばれる現象です。
対策は、テーマ適用部分だけを <head> 内のインラインスクリプトにして、CSS読み込みの前に走らせることです。
<!-- 各HTMLの <head> 内。<link rel="stylesheet"> より前に置く -->
<script>
(function () {
var KEY = 'site-theme';
try {
var saved = localStorage.getItem(KEY);
if (saved === 'dark' || saved === 'light') {
document.documentElement.setAttribute('data-theme', saved);
}
} catch (e) {
/* localStorage が使えない環境では OS 追従のまま */
}
})();
</script>
<link rel="stylesheet" href="/assets/css/style.css">
変更する箇所: KEY の値 'site-theme' を、サイトごとに衝突しない名前にします(example-theme など)。CSSのパスも自サイトに合わせます。
このスクリプトは外部ファイルにしてはいけません。<script src> は取得のためのネットワーク往復が入り、その間に描画が進んでちらつきが出ます。インラインで、かつ数行に抑えるのが要点です。try...catch を入れているのは、プライベートブラウズやCookieブロックの設定で localStorage へのアクセスが例外を投げる環境があるためです。ここで例外が出て止まると、以降のスクリプトが全部死にます。
切り替えボタンのHTML
<!-- ヘッダーなど、全ページ共通の場所に置く -->
<div class="theme-switch" role="group" aria-label="配色テーマ">
<button type="button" class="theme-switch__btn" data-set-theme="light" aria-pressed="false">ライト</button>
<button type="button" class="theme-switch__btn" data-set-theme="dark" aria-pressed="false">ダーク</button>
<button type="button" class="theme-switch__btn" data-set-theme="auto" aria-pressed="true">端末に合わせる</button>
</div>
role="group" と aria-label を付けているのは、スクリーンリーダーで3つのボタンが何のグループか分かるようにするためです。aria-pressed は現在の選択状態を示し、JavaScript側で更新します。
切り替えボタンのCSS
/* /assets/css/style.css */
.theme-switch {
display: inline-flex;
gap: 2px;
padding: 3px;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
}
.theme-switch__btn {
padding: 6px 14px;
border: none;
border-radius: 999px;
background: transparent;
color: var(--color-text-muted);
font-size: 13px;
line-height: 1.4;
cursor: pointer;
transition: background-color .15s, color .15s;
}
.theme-switch__btn:hover {
color: var(--color-text);
}
.theme-switch__btn[aria-pressed="true"] {
background: var(--color-surface-raised);
color: var(--color-text);
box-shadow: var(--shadow-card);
}
.theme-switch__btn:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
:focus-visible を使うと、マウスクリック時は輪郭が出ず、キーボード操作のときだけ出ます。アクセシビリティを落とさずに見た目を保てるので、outline: none で消すのではなくこちらを使います。
切り替えロジック
/* /assets/js/theme.js ── </body> の直前で読み込む */
(function () {
'use strict';
var KEY = 'site-theme'; // ← head のインラインと同じ値にする
var root = document.documentElement;
var media = window.matchMedia('(prefers-color-scheme: dark)');
function read() {
try {
var v = localStorage.getItem(KEY);
return (v === 'dark' || v === 'light') ? v : 'auto';
} catch (e) {
return 'auto';
}
}
function write(value) {
try {
if (value === 'auto') {
localStorage.removeItem(KEY);
} else {
localStorage.setItem(KEY, value);
}
} catch (e) {
/* 保存できなくても画面の切り替えは動かす */
}
}
function apply(value) {
if (value === 'auto') {
root.removeAttribute('data-theme');
} else {
root.setAttribute('data-theme', value);
}
syncButtons(value);
notify(value);
}
function syncButtons(value) {
var btns = document.querySelectorAll('[data-set-theme]');
for (var i = 0; i < btns.length; i++) {
var isOn = btns[i].getAttribute('data-set-theme') === value;
btns[i].setAttribute('aria-pressed', isOn ? 'true' : 'false');
}
}
function isDarkNow(value) {
if (value === 'dark') return true;
if (value === 'light') return false;
return media.matches;
}
function notify(value) {
document.dispatchEvent(new CustomEvent('themechange', {
detail: { setting: value, isDark: isDarkNow(value) }
}));
}
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-set-theme]');
if (!btn) return;
var value = btn.getAttribute('data-set-theme');
write(value);
apply(value);
});
// OS 側の設定が変わったとき(auto のときだけ反応させる)
media.addEventListener('change', function () {
if (read() === 'auto') notify('auto');
});
// 他タブで切り替えられたら追従する
window.addEventListener('storage', function (e) {
if (e.key === KEY) apply(read());
});
apply(read());
})();
変更する箇所: KEY を <head> のインラインスクリプトと同じ文字列にします。ここがずれると保存した値が読めず、リロードのたびに設定が消えたように見えます。
読み込みは通常どおりで構いません。
<script src="/assets/js/theme.js" defer></script>
最後の themechange カスタムイベントは、テーマに応じて再描画が必要な部品(後述の地図やグラフ)から購読するための口です。不要なら notify() の呼び出しごと削っても動きます。
画像・ロゴ・SVGを差し替える

ロゴなど固定の画像:picture要素で切り替える
JavaScript を使わずに、HTML だけで差し替えられます。
<picture>
<source
srcset="/assets/img/logo-dark.svg"
media="(prefers-color-scheme: dark)">
<img
src="/assets/img/logo-light.svg"
alt="サイト名"
width="160"
height="32">
</picture>
変更する箇所: 2つの画像パス、alt のサイト名、width / height の実寸。
width と height は必ず書きます。読み込み前に領域が確保され、レイアウトのずれ(CLS)を防げます。
ただしこの書き方は OS設定にしか反応しません。手動切り替えで data-theme="dark" を付けても、<picture> の media 属性はメディアクエリを見ているだけなので画像は変わりません。手動切り替えを併用するなら、次のCSSでの差し替えを使います。
CSSで画像を差し替える
<img> をやめて、背景画像にする方法です。属性による上書きが効きます。
.site-logo {
display: block;
width: 160px;
height: 32px;
background-image: url("/assets/img/logo-light.svg");
background-size: contain;
background-repeat: no-repeat;
/* 画像が読めないときのための代替テキスト表示 */
text-indent: -9999px;
overflow: hidden;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .site-logo {
background-image: url("/assets/img/logo-dark.svg");
}
}
:root[data-theme="dark"] .site-logo {
background-image: url("/assets/img/logo-dark.svg");
}
<a href="/" class="site-logo">サイト名</a>
背景画像はスクリーンリーダーに読まれないため、要素内にテキストを置いて text-indent で画面外へ送っています。aria-label でも代替できますが、画像がブロックされた環境ではテキストが残るこちらのほうが堅実です。
変更する箇所: 画像パス、width / height、クラス名。
インラインSVG:currentColorで色を継承させる
アイコンや単色ロゴなら、SVG をHTMLに直接書いて fill="currentColor" にするのが一番手数が少なく済みます。2枚用意する必要がなくなります。
<svg class="icon" width="20" height="20" viewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path d="M10 2a8 8 0 1 0 0 16A8 8 0 0 0 10 2zm0 2.5a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11z"/>
</svg>
.icon {
color: var(--color-text-muted);
vertical-align: -.2em;
}
fill="currentColor" を書いておくと、SVG は親要素の color を継承します。変数が切り替われば自動でアイコンの色も変わります。装飾目的なら aria-hidden="true" を付けて読み上げから外します。
外部ファイルの <img src="icon.svg"> ではこの継承が効きません。SVGをインライン化するか、後述の filter で処理します。
写真:暗い側で少し落とす
写真は差し替える必要はありませんが、暗い背景の上では明るすぎて浮きます。輝度を1割ほど落とすと馴染みます。
:root[data-theme="dark"] .article-body img,
:root:not([data-theme="light"]) .article-body img {
filter: brightness(.92);
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) .article-body img {
filter: none;
}
}
変更する箇所: .article-body を自サイトの本文コンテナのクラスに。数値 .92 は写真の傾向に合わせて 0.88〜0.95 で調整します。
ロゴや図版まで暗くならないよう、セレクタは本文の画像に限定します。サイト全体の img に当てると、ヘッダーのロゴまで沈みます。
白背景が透けている図版
透過PNGで白い線画が入っている図版は、ダークモードで完全に消えます。この場合、画像そのものを反転させる手があります。
:root[data-theme="dark"] .invert-on-dark {
filter: invert(1) hue-rotate(180deg);
}
invert(1) だけだと色相が反転して不自然になるため、hue-rotate(180deg) で戻します。モノクロの図版であれば invert(1) 単体で十分です。あくまで応急処置なので、重要な図版はダーク用を作るほうが確実です。
CMSに載せる場合の置き場所

WordPress
<head> へのインラインスクリプト挿入は wp_head フックで行います。子テーマの functions.php に書きます。
<?php
/* /wp-content/themes/子テーマ名/functions.php に追記 */
add_action( 'wp_head', 'mytheme_dark_mode_boot', 1 );
function mytheme_dark_mode_boot() {
?>
<script>
(function () {
var KEY = 'site-theme';
try {
var saved = localStorage.getItem(KEY);
if (saved === 'dark' || saved === 'light') {
document.documentElement.setAttribute('data-theme', saved);
}
} catch (e) {}
})();
</script>
<?php
}
変更する箇所: mytheme_ の接頭辞を自分のテーマ名に、KEY の値をサイト固有の名前に。
add_action の第3引数を 1 にしているのは、優先度を上げてスタイルシートの出力より前に走らせるためです。既定値の 10 だと、テーマやプラグインの wp_enqueue_style の後に出力されてちらつきが残ることがあります。
theme.js は wp_enqueue_script で読み込みます。
add_action( 'wp_enqueue_scripts', 'mytheme_dark_mode_assets' );
function mytheme_dark_mode_assets() {
wp_enqueue_script(
'mytheme-theme-switch',
get_stylesheet_directory_uri() . '/assets/js/theme.js',
array(),
'1.0.0',
true
);
}
子テーマの構成そのものに不安がある場合は、先にこちらを確認してください。
親テーマを直接編集すると、アップデートで全部消えます。ダークモード対応のように広範囲を触る改修ほど、子テーマで進める意味が大きくなります。
Shopify(Online Store 2.0 のテーマ)
layout/theme.liquid の <head> 内、{{ content_for_header }} の直後あたりにインラインスクリプトを置きます。
{%- comment -%} layout/theme.liquid の <head> 内 {%- endcomment -%}
<script>
(function () {
var KEY = 'shop-theme';
try {
var saved = localStorage.getItem(KEY);
if (saved === 'dark' || saved === 'light') {
document.documentElement.setAttribute('data-theme', saved);
}
} catch (e) {}
})();
</script>
色変数は assets/ 配下のCSSに置くか、テーマ設定から流し込みます。テーマエディタで色を管理したい場合は settings_schema.json に色設定を足し、theme.liquid でCSS変数として出力します。
{%- comment -%} <head> 内、スタイルシート読み込みの直前 {%- endcomment -%}
<style>
:root {
--color-bg: {{ settings.color_bg | default: '#ffffff' }};
--color-text: {{ settings.color_text | default: '#1a1a1a' }};
}
:root[data-theme="dark"] {
--color-bg: {{ settings.color_bg_dark | default: '#15171a' }};
--color-text: {{ settings.color_text_dark | default: '#e8eaed' }};
}
</style>
変更する箇所: 設定キー名(color_bg など)を settings_schema.json で定義したIDに合わせます。
Dawn ベースのテーマを触るときの進め方は、アップデート耐性の観点で別途注意点があります。
Shopify Dawnテーマのカスタマイズ方針|アップデートに耐える改修の進め方
Dawn 本体のCSS変数と名前がぶつかると、意図しない箇所まで色が変わります。独自の変数には接頭辞を付けて分離しておくのが安全です。
忘れやすい細部

theme-color メタタグ
モバイルブラウザのアドレスバーの色です。ライト/ダークで別々に指定できます。
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#15171a" media="(prefers-color-scheme: dark)">
media 属性付きの theme-color は Safari 15以降 / Chrome 93以降で有効です。手動切り替えにも追従させたい場合は、JavaScript で書き換えます。
document.addEventListener('themechange', function (e) {
var meta = document.querySelector('meta[name="theme-color"]:not([media])');
if (!meta) {
meta = document.createElement('meta');
meta.setAttribute('name', 'theme-color');
document.head.appendChild(meta);
}
meta.setAttribute('content', e.detail.isDark ? '#15171a' : '#ffffff');
});
変更する箇所: 2つの色コードを自サイトの背景色に。
切り替え時のアニメーション
transition を * に当てると、ページ読み込み時にも走って全要素が数百ミリ秒かけて色づきます。これは避けます。
/* やらない */
* { transition: background-color .3s, color .3s; }
切り替え時だけ短いトランジションを付けたいなら、クラスで制御します。
.theme-transitioning,
.theme-transitioning * {
transition: background-color .18s ease, border-color .18s ease, color .18s ease !important;
}
// theme.js の apply() の先頭に足す
function apply(value) {
root.classList.add('theme-transitioning');
window.setTimeout(function () {
root.classList.remove('theme-transitioning');
}, 200);
// …以降は既存の処理
}
さらに、動きを減らす設定をしているユーザーには適用しないようにします。
@media (prefers-reduced-motion: reduce) {
.theme-transitioning,
.theme-transitioning * {
transition: none !important;
}
}
印刷
印刷時にダークのままだとインクを無駄に消費し、紙面も読みにくくなります。強制的にライトへ戻します。
@media print {
:root,
:root[data-theme="dark"] {
--color-bg: #ffffff;
--color-surface: #ffffff;
--color-surface-raised: #ffffff;
--color-text: #000000;
--color-text-muted: #444444;
--color-border: #cccccc;
--shadow-card: none;
}
.theme-switch { display: none; }
img { filter: none !important; }
}
.theme-switch を非表示にするのは、印刷物に操作UIが出ても意味がないためです。
埋め込みコンテンツ
YouTube、Googleマップ、SNSの埋め込みは、自サイトのCSSが届きません。白い矩形が暗い背景に浮くので、周囲に余白と枠を付けて「埋め込みである」ことが分かるようにするか、埋め込みのパラメータで暗いテーマを指定できるものはそちらを使います。Googleマップの埋め込みは標準では色を変えられないため、境界を明示する処理のほうが現実的です。
.embed-frame {
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
background: var(--color-surface);
}
うまくいかないとき

リロードすると設定が戻る
<head> のインラインスクリプトと theme.js で、localStorage のキー名がずれています。両方の KEY を同じ文字列にしてください。開発者ツールの Application タブ → Local Storage で、実際に保存されている値とキー名を確認できます。
切り替えの瞬間に白くちらつく
テーマ適用スクリプトが <head> に入っていないか、<link rel="stylesheet"> より後ろにあります。<head> 内の、スタイルシート読み込みより前に置き直してください。<script src> の外部ファイルにしている場合もちらつきます。インラインに戻します。
OSをダークにしても変わらない
3つ確認します。prefers-color-scheme のメディアクエリ内のセレクタが :root:not([data-theme]) になっていて、かつ <html> に data-theme が付いていないか。開発者ツールの Elements で <html> タグを見れば分かります。次に、メディアクエリの書式に余計なスペースが入っていないか(( prefers-color-scheme : dark ) は無効です)。最後に、CSSの読み込み順で後ろから上書きされていないか。
セレクトボックスやスクロールバーだけ白いまま
color-scheme の指定漏れです。:root と :root[data-theme="dark"] の両方に書きます。この1行がないと、ブラウザ描画のUI部品はOS設定を無視してライト固定になります。
一部の文字が読めない
変数化の漏れです。CSSを (color|background)[^;{]*:\s*(#|rgb) で正規表現検索し、:root の外に残っている色指定を潰します。インラインスタイル(style="color:#333")やCMSのエディタが吐いた色指定も対象です。WordPress のブロックエディタで本文に色を指定していると、ここに引っかかります。
影が見えない
暗い背景では影はほぼ機能しません。--color-surface-raised を背景より明るい色にして、明度差で階層を作ってください。影を使うなら不透明度を上げます(rgba(0,0,0,.5) 程度)。
iOSで背景色がスクロール中に変わる
body にのみ背景色を指定していると、コンテンツ高さを超えてスクロールした領域が白く見えます。html にも同じ背景色を当てます。
html,
body {
background: var(--color-bg);
}
他のタブと表示がずれる
storage イベントの購読を入れていれば追従します。上の theme.js には含まれています。入れていない場合、切り替えたタブ以外は次のリロードまで古い状態のままになります。
よくある質問

prefers-color-scheme だけで対応するのは不十分ですか
サイトの性質によります。読み物中心のメディアや管理画面のように滞在時間が長いものは、手動切り替えを付ける価値があります。企業サイトやキャンペーンLPなど短時間の閲覧が中心なら、OS追従だけで十分実用になります。手動切り替えは実装量が数倍になるので、必要性を判断してから着手してください。
ダークモード対応はSEOに影響しますか
配色の切り替え自体が検索順位に直接効くことはありません。ただし実装の副作用として、変数の指定漏れでコントラストが不足すると、モバイルでの使いやすさに問題が出ます。実装後は本文テキストのコントラスト比が4.5:1以上あることを開発者ツールで確認してください。
localStorage ではなく Cookie に保存すべきですか
サーバーサイドレンダリングでHTMLの生成時からテーマを確定させたい場合は Cookie が必要です。静的サイトや通常のCMSであれば localStorage で問題ありません。Cookie にすると同意管理の対象になるか検討が必要になるため、機能的に必要な場面でだけ使うのが無難です。
既存サイトに後から入れる場合、どのくらいの作業量ですか
分量を決めるのは色変数への置き換え工程です。色指定が整理されているサイトなら半日程度、ページごとに色がハードコードされている場合は、変数への集約だけで数日かかることもあります。先にCSS内の色指定を正規表現で数えてみると、見積もりの目安が立ちます。
画像を2種類用意するのは必須ですか
単色のロゴやアイコンであれば、インラインSVGに fill="currentColor" を書けば1つで済みます。2種類必要になるのは、ロゴが多色でダーク背景に耐えないケースと、白背景を前提にした図版です。写真は差し替え不要で、filter: brightness() の微調整で対応できます。
まとめ
ダークモード対応の作業は、実質的に「色の棚卸し」です。役割ベースの変数名でCSS変数に集約できていれば、prefers-color-scheme での上書きは十数行で終わります。手動切り替えを足すときは、<head> のインラインスクリプトでちらつきを止め、auto を含む3状態で設計するのが要点です。
最後に、実装が終わったら次の5点を実機で確認してください。
- OSをダークに切り替えて、ページ全体が追従するか
- 手動でライトを選んだあと、OSがダークでもライトのままか
- リロード後も選択が保持されるか
- セレクトボックスとスクロールバーが暗くなっているか(
color-scheme) - 本文のコントラスト比が4.5:1以上あるか
ページネーションやパンくずのように自作した部品は、変数化の漏れが出やすい場所です。テンプレートを自作している場合は、そちらも合わせて点検しておくと取りこぼしが減ります。


