Next.jsブログにライト/ダークモード切り替えを実装する方法
- この記事で分かること
- 自前でライト/ダーク切り替えを実装するときの、CSS変数の設計・設定の保存・ちらつき防止(FOUC対策)という3つの考え方とコード
- 実際に使った環境
- Next.js App Router / React useSyncExternalStore。このブログの本番実装(ヘッダーのテーマ切り替えボタン)と同じ構成です
- 筆者の結論
- 切り替えボタン自体の実装より、ページを開いた瞬間に一瞬だけ色がちらつく問題(FOUC)への対策の方が実装の肝でした
ブログを作るとき、「端末のダークモード設定に自動で追従してほしいけど、手動でも切り替えられるようにしたい」というのはよくある要望です。このブログでも実際にこの仕組みを実装しているので、その考え方とコードを紹介します。
🛠 実装してみて分かったこと
実装してみて分かったのですが、この機能で一番厄介なのは「切り替えボタンをクリックしたときの見た目」ではなく、「ページを開いた瞬間に一瞬だけ違う色がちらつく」問題(俗に言うFOUC)でした。サーバー側では常にライトモードでHTMLを組み立てるため、ユーザーがダーク固定を選んでいると、読み込み直後の一瞬だけ白い画面が見えてしまいます。これを防ぐために、Reactが動き出す前に小さなスクリプトで先に色を決めてしまう、という一見地味な工夫が必要でした。見た目上は本当に些細な違いですが、体感の質はかなり変わる部分だと思います。
実装の考え方
実際にこのブログで動いている状態はこちらです。同じトップページを、ライトモードとダークモードそれぞれで開いたものです。


- CSS変数でライト/ダークの配色を定義する:
:rootにライト用の値、prefers-color-scheme: darkのメディアクエリにダーク用の値を定義します - 手動切り替え用の属性を用意する:
<html data-theme="dark">のように属性を付け、CSSセレクタでこの属性がある場合は端末設定より優先させます - 選択内容をlocalStorageに保存する: 再訪問時も同じ設定が復元されるようにします
- ちらつき防止スクリプトを仕込む: Reactのハイドレーションより前に、保存された設定を
<html>に反映させます
CSSの構成
:root {
--background: #ffffff;
--foreground: #24262e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--background: #24262e;
--foreground: #f1ece6;
}
}
:root[data-theme="dark"] {
--background: #24262e;
--foreground: #f1ece6;
}ポイントは、:root[data-theme="dark"]を単独のセレクタとしても用意しておくことです。これにより、端末の設定がライトであっても、ユーザーが明示的にダークを選んだ場合はそちらを優先させられます。
切り替えボタンの実装
ReactのuseSyncExternalStoreを使うと、localStorageのような外部の値と画面表示を安全に同期できます。useEffectの中でsetStateする書き方でも動作はしますが、最近のESLintルールでは推奨されない実装として警告されることがあります。
"use client";
import { useSyncExternalStore } from "react";
type ThemePreference = "system" | "light" | "dark";
function isThemePreference(value: string | null): value is ThemePreference {
return value === "system" || value === "light" || value === "dark";
}
function getSnapshot(): ThemePreference {
const stored = localStorage.getItem("theme-preference");
return isThemePreference(stored) ? stored : "system";
}
function getServerSnapshot(): ThemePreference {
return "system"; // サーバーにはlocalStorageが無いため固定値を返す
}localStorageの値は、手動での書き換えや将来の仕様変更で"system"/"light"/"dark"以外の値が入っている可能性もゼロではありません。isThemePreferenceという型ガード関数で値を検証し、想定外の値だった場合は安全側の"system"にフォールバックするようにしています。
ちらつき防止スクリプト
保存された設定を即座に<html>へ反映する小さなスクリプトを用意し、layout.tsxからnext/scriptのstrategy="beforeInteractive"で出力します。
import Script from "next/script";
const themeInitScript = `(function(){try{var t=localStorage.getItem('theme-preference');if(t==='light'||t==='dark'){document.documentElement.setAttribute('data-theme',t);}}catch(e){}})();`;
// RootLayoutの<body>内
<Script id="theme-init" strategy="beforeInteractive" dangerouslySetInnerHTML={{ __html: themeInitScript }} />beforeInteractiveを指定したスクリプトは初期HTMLに含めて出力され、Reactの水和(ハイドレーション)より前に実行されます。そのため、ダーク固定を選んでいるユーザーに一瞬白い画面が見える、という現象を防げます。
当初は<body>の先頭に生の<script>タグとして直接書いていましたが、2026年9月にnext/scriptを使う書き方へ切り替えました。インラインスクリプトを水和前に実行したい場合は、Next.jsが用意しているこの指定を使うのが想定された書き方です。<html>タグにはsuppressHydrationWarningを付けています。このスクリプトがReactより先にdata-theme属性を書き換えるため、サーバーで生成したHTMLとの差分を警告されないようにするためです。
よくある質問
Q. ちらつき防止スクリプトを入れないとどうなりますか? A. ダーク固定を選んでいるユーザーが再訪問したときに、一瞬だけライト画面が表示されてから切り替わる、という見た目のノイズが発生します。致命的な不具合ではありませんが、細部の品質として気になる人には気になる部分です。
実装してみて
CSS変数の設計・設定の保存・ちらつき防止という3つを押さえれば、機能自体の実装はそれほど複雑ではありませんでした。想定より時間がかかったのはちらつき防止の1点だけで、逆に言えばそこさえ乗り越えれば残りは素直に実装が進みます。このブログでも実際にこの構成のまま運用しています。