ダークモードの最初の一瞬だけ白い。あのちらつきを消す実装手順
ダークモード実装でほぼ全員がハマる「開いた瞬間だけ白くなる」ちらつき。CSS変数・prefers-color-scheme・head内スクリプトで消す手順を、僕の失敗込みで解説。
自分のブログにダークモードを付けて、得意げにスマホで開いた瞬間でした。
画面が一瞬だけ真っ白にチカッと光ってから、スッと暗くなる。コンマ数秒の話です。でも一度気づくと、ページを開くたびに目に刺さる。「実装できた」と思っていたのに、いちばん最初の0.1秒で安っぽく見えてしまうんですね。
このチカッの正体は FOUC(Flash of Unstyled Content)、日本語でいう「テーマのちらつき」です。今日はこれを確実に消すところを中心に、ダークモードを小さく安全に入れる手順を、僕がやらかした失敗込みでまとめます。
この記事の要点
- ダークモードは「色を反転」ではなく「CSS変数を2セット切り替える」設計にすると壊れにくい。
- 開いた瞬間のちらつき(FOUC)は、テーマ判定スクリプトを
<head>内で同期実行 すれば消える。本文より先にテーマを確定させるのがコツ。 - 端末の設定は
prefers-color-schemeで読み、ユーザーが切り替えたらlocalStorageに保存して次回から優先する。 - 色を暗くすると 補足テキスト・透過ロゴ・影 が消える。コントラスト比4.5:1を目安に、影は枠線で表現し直す。
- 色だけで状態を伝えない。アイコンとラベルを添えると色覚特性のある読者にも届く。
ダークモードは「反転」じゃない、変数の切り替えだ
最初、僕は filter: invert(1) で全部ひっくり返せば終わりだと思っていました。これは事故ります。写真が現像ミスみたいになるし、ブランドカラーも変な色に化けます。
ちゃんとした作り方はシンプルで、色を変数にまとめて、ライト用とダーク用の2セットを用意し、切り替えるだけです。各パーツのCSSは「変数を見る」ように書いておく。すると、テーマを切り替えても触るのは変数の値だけで、レイアウトのCSSは1行も書き換えずに済みます。
まず変数を定義します。:root がライト、[data-theme="dark"] がダークです。
:root {
color-scheme: light;
--color-page: #ffffff;
--color-surface: #f8fafc;
--color-text: #0f172a;
--color-muted: #475569;
--color-border: #dbe3ef;
--color-link: #2563eb;
--color-focus: #f59e0b;
}
[data-theme="dark"] {
color-scheme: dark;
--color-page: #0b1120;
--color-surface: #111827;
--color-text: #f8fafc;
--color-muted: #cbd5e1;
--color-border: #334155;
--color-link: #93c5fd;
--color-focus: #fbbf24;
}
/* 各パーツは「変数を見る」だけ。色の直書きをしない */
body {
background: var(--color-page);
color: var(--color-text);
}
:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 3px;
}
ここで地味に効くのが color-scheme の1行です。これを書いておくと、スクロールバーやテキスト入力欄など、ブラウザが描く部品まで暗くしてくれます。これがないと、本文は暗いのにスクロールバーだけ白い、という気持ち悪い状態になります。color-scheme の挙動は MDN: color-scheme が詳しいです。
CSS変数そのものの設計(命名やトークン化)をもっと詰めたい人は、別記事の Claude CodeでCSS変数を実務投入する設計ガイド に分けて書いたので、そちらも合わせてどうぞ。
端末の設定を尊重しつつ、ユーザーの選択を覚える
テーマをどう決めるか。ここで欲張ると、すぐ複雑になります。僕がたどり着いた答えはこれだけです。
- ユーザーが過去に選んだ設定(
localStorage)があれば、それを最優先。 - なければ、端末の設定(OSのダークモード)を見る。これが
prefers-color-scheme。 - それでも分からなければ、ライトにする。
JavaScriptで書くとこうなります。短いですが、これがテーマ判定の全体像です。
const storageKey = "theme";
const root = document.documentElement;
// 1) 保存済みの選択 → 2) 端末設定 → 3) ライト の順で決める
const stored = localStorage.getItem(storageKey);
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme =
stored === "light" || stored === "dark"
? stored
: prefersDark
? "dark"
: "light";
root.dataset.theme = theme;
prefers-color-scheme はOSの「ダーク/ライト」設定をそのまま読めるメディア特性です。詳しい仕様は MDN: prefers-color-scheme を見てください。
トグルボタンを置くなら、ライト/ダークに加えて「システムに従う」の3択にしておくと親切です。aria-pressed で今どれが選ばれているかを支援技術にも伝えます。
type Theme = "light" | "dark" | "system";
export function ThemeToggle({
value,
onChange,
}: {
value: Theme;
onChange: (theme: Theme) => void;
}) {
return (
<fieldset aria-label="テーマ切り替え">
{(["light", "dark", "system"] as const).map((theme) => (
<button
key={theme}
type="button"
aria-pressed={value === theme}
onClick={() => onChange(theme)}
>
{theme}
</button>
))}
</fieldset>
);
}
「システムに従う」を残しておくと、昼はライト・夜はダークに自動で切り替わるOSの設定にも素直に乗れます。最初から消さないほうがいいです。
本題:開いた瞬間の「白いちらつき」を消す
ここが冒頭のチカッの話です。
ちらつきが起きる理由は、順番の問題です。ブラウザはまずHTMLを表示し、そのあとにJavaScriptを読み込んで動かします。テーマを付ける処理が「あと」に走ると、その待ち時間のあいだは初期色——多くの場合は明るい色——が一瞬見えてしまう。だから白くチカッとするわけです。
僕の最初の実装は、まさにこれでした。Reactのコンポーネントが立ち上がってから data-theme を付けていたので、毎回ちらつく。CSSをいじっても直りません。順番の問題だから、色の問題として直そうとしても永遠に直らないんです。
解決策は1つ。テーマを決める短いスクリプトを、本文より前、つまり <head> の中で同期的に走らせること。画面が描かれる前にテーマを確定させてしまえば、ちらつく隙がなくなります。
<!-- このスクリプトは <head> の中、できるだけ早い位置に直接書く -->
<script>
(function () {
var stored = localStorage.getItem("theme");
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
var theme =
stored === "light" || stored === "dark"
? stored
: prefersDark
? "dark"
: "light";
// 本文が描かれる前に data-theme を確定させる
document.documentElement.dataset.theme = theme;
})();
</script>
コツは、このスクリプトをとにかく軽くすること。ここに重い処理やReactの初期化を混ぜると、結局その分だけ描画が待たされて、ちらつきが戻ってきます。やることは「保存済みの設定と端末設定を見て data-theme を付ける」だけ。それ以上は入れない。
AstroやNext.jsのようにサーバー側でHTMLを作るフレームワークでも、考え方は同じです。サーバーは「その端末がダーク設定かどうか」を知りようがないので、ハイドレーション(サーバーで作ったHTMLに、ブラウザ側で動きを足す処理)を待たずに、この小さなスクリプトだけ別扱いで先に走らせます。初回は端末設定を尊重し、ユーザーが切り替えたら localStorage に保存して次回から優先する。この流れがいちばん素直です。
色を暗くすると消えるもの:コントラスト・画像・影
ちらつきが消えると満足してしまいますが、ダークモードの本当の落とし穴はここからです。明るい画面で十分だった要素が、暗い背景だと急に見えなくなる。
僕が見落としたのは、補足テキストでした。ライトでは薄いグレーでちょうど良かった注釈が、ダークの背景だと沈んで読めない。本文と背景のコントラスト比は、WCAGが示す 4.5:1以上 を目安にします(基準の詳細は WCAG: コントラスト(最低限))。薄いグレーの注釈、プレースホルダー、無効状態のボタンは、暗い背景でいちばん最初に読めなくなる場所です。
画像と影も要注意です。白背景を前提に作った透過PNGのロゴは、暗い背景だと縁が消えて何が描いてあるか分からなくなります。背景色を敷くか、ダーク用の差し替えを用意します。影も同じで、明るい背景では境界として効いていた影が、暗い背景ではほぼ見えません。影で見せていた境目は、--color-border のような枠線で表現し直すと、どちらのテーマでも構造が崩れません。
| 要素 | ライトでの前提 | ダークでの調整 |
|---|---|---|
| 補足テキスト | 薄いグレーで十分 | 明度を上げてコントラスト確保 |
| ロゴ・アイコン | 白背景前提 | 背景を敷くかダーク用に差し替え |
| 影 | 濃いグレーで境界を表現 | 枠線(border)で表現し直す |
| 状態色(成功/警告) | 鮮やかな緑・赤 | 彩度を落とし、アイコンも添える |
最後に状態色。成功は緑、警告は赤、みたいなやつです。彩度の高い色は暗い背景でギラついて、かえって読みにくい。ダーク用に少し落ち着いた色を用意します。そして色だけで意味を伝えないこと。チェックや三角のアイコン、ラベルを添えておけば、コントラストが弱い環境でも、色覚特性のある読者にも伝わります。アクセシビリティ全体の進め方は Claude Codeでアクセシビリティ対応を実装する実践ワークフロー にまとめました。
Claude Codeに任せるとき、僕が渡す指示
ここまでの手順は、Claude Codeに丸投げで一発で出させようとすると、だいたい崩れます。「いい感じにダークモードにして」だけだと、filter: invert で雑に反転されたり、ちらつき対策が抜けたりする。
なので僕は、対象ファイル・禁止事項・確認観点を先に渡すようにしています。完成形のUIを作らせるのではなく、壊れにくい変更プロセスを作らせるイメージです。
この画面にダークモードを実装してください。
【対象】styles/theme.css と <head> の初期化スクリプトのみ。コンポーネントのロジックは変更しない。
【禁止】filter:invert での反転。色の直書き。既存のクラス名・命名規則の変更。
【方針】色はCSS変数を2セット(:root と [data-theme="dark"])。FOUC防止のため
テーマ判定は <head> 内で同期実行する。
【確認観点】(1) 開いた瞬間のちらつきがないか (2) 補足テキストとプレースホルダーのコントラスト
(3) 透過ロゴと影 (4) スマホ375px幅 (5) キーボードのフォーカス表示
最後に、変更ファイル・削れる処理・手動で確認すべき点を箇条書きで返してください。
このプロンプトの狙いは、派手な改善ではなく「離脱を増やさないこと」です。既存サイトでは、CTAやフォーム、価格表の可読性を落とさないことのほうが、見た目の格好良さより大事だったりします。実装・レビュー・修正を一度に頼まず、3段階に分けると差分が読みやすく、品質も安定します。
実際にレビューさせるときは、「ダーク背景でコントラストが落ちる箇所」「白前提の画像」「影で表現している境界」を具体的に名指しで探させると、見落としが減ります。チェックリスト形式のレビュー運用は Claude Codeでコードレビューを設計する が参考になります。
よくある質問
Q. ちらつきが、対策しても直りません。
A. ほぼ間違いなく、テーマ判定スクリプトの「位置」か「重さ」の問題です。<head> の中、できるだけ早い位置に直接インラインで書いていますか。外部ファイル化して <script src> で読み込むと、その読み込みを待つ分ちらつきます。また、そのスクリプト内でReactの初期化など重い処理をしていないか確認してください。
Q. CSS変数とTailwindのdarkクラス、どちらがいいですか。
A. どちらでも消せます。Tailwindなら dark: バリアントで完結しますし、CSS変数なら data-theme 一発で全体を切り替えられます。色のトークンを他の用途(メール、図版など)でも使い回すなら、CSS変数のほうが取り回しが楽です。判定スクリプトを <head> で先に走らせる原則は、どちらでも変わりません。
Q. システム設定だけに従わせて、トグルは省略してもいい? A. 動きはします。ただ「サイトだけダークにしたい/ライトにしたい」読者を取りこぼします。OSとは別にサイト側で選べると親切なので、最低限ライト/ダーク、できれば「システムに従う」を足した3択をおすすめします。
Q. localStorage が使えない環境(プライベートモード等)は?
A. 保存に失敗しても、毎回 prefers-color-scheme にフォールバックするので表示自体は崩れません。気になるなら localStorage の読み書きを try/catch で囲み、失敗しても落ちないようにしておくと安全です。
Q. ダークモードはSEOやアクセシビリティに影響しますか。 A. ダークモード自体が直接ランキングを上げるわけではありません。ただ、コントラスト不足やちらつきは体験を悪化させ、離脱につながります。逆にコントラストを正しく確保すれば、読みやすさ=滞在時間という形で間接的に効いてきます。
実際に試した結果
この手順は、自分のブログUIを壊さない前提で、スマホ幅375px・通常表示・低速回線・キーボード操作を一通り見ながら確認しました。
いちばん効いたのは、やはり判定スクリプトを <head> に移したことです。Reactの中で data-theme を付けていたのをやめ、本文より前に同期実行する数行に置き換えただけで、あの白いチカッが完全に消えました。次に効いたのが補足テキストの明度調整で、コントラスト比を測りながら直したら、夜にスマホで読んだときの「目が滑る感じ」がなくなりました。
ダークモードは装飾じゃなくて、読者の迷いを減らす改善です。色を反転して終わりにせず、ちらつきとコントラストの2点だけは丁寧にやる。これだけで、見た目の安っぽさはほぼ消えます。
自社サイトでダークモードやレビュー体制をちゃんと整えたい方は、Claude Code研修・相談 で既存画面を題材に手順を相談できます。手を動かす教材を探しているなら 教材一覧 もどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。