AIツクリラボ
(更新: )・Next.js制作Tips・7分で読めます・🔵 実装記録

Next.jsブログにライト/ダークモード切り替えを実装する方法

この記事で分かること
自前でライト/ダーク切り替えを実装するときの、CSS変数の設計・設定の保存・ちらつき防止(FOUC対策)という3つの考え方とコード
実際に使った環境
Next.js App Router / React useSyncExternalStore。このブログの本番実装(ヘッダーのテーマ切り替えボタン)と同じ構成です
筆者の結論
切り替えボタン自体の実装より、ページを開いた瞬間に一瞬だけ色がちらつく問題(FOUC)への対策の方が実装の肝でした

ブログを作るとき、「端末のダークモード設定に自動で追従してほしいけど、手動でも切り替えられるようにしたい」というのはよくある要望です。このブログでも実際にこの仕組みを実装しているので、その考え方とコードを紹介します。

🛠 実装してみて分かったこと

実装してみて分かったのですが、この機能で一番厄介なのは「切り替えボタンをクリックしたときの見た目」ではなく、「ページを開いた瞬間に一瞬だけ違う色がちらつく」問題(俗に言うFOUC)でした。サーバー側では常にライトモードでHTMLを組み立てるため、ユーザーがダーク固定を選んでいると、読み込み直後の一瞬だけ白い画面が見えてしまいます。これを防ぐために、Reactが動き出す前に小さなスクリプトで先に色を決めてしまう、という一見地味な工夫が必要でした。見た目上は本当に些細な違いですが、体感の質はかなり変わる部分だと思います。

実装の考え方

実際にこのブログで動いている状態はこちらです。同じトップページを、ライトモードとダークモードそれぞれで開いたものです。

AIツクリラボのトップページをライトモードで表示した画面
ライトモード
AIツクリラボのトップページをダークモードで表示した画面。ヘッダー・背景・アイキャッチのイラストの配色が反転している
ダークモード(手動切り替え)
  1. CSS変数でライト/ダークの配色を定義する: :rootにライト用の値、prefers-color-scheme: darkのメディアクエリにダーク用の値を定義します
  2. 手動切り替え用の属性を用意する: <html data-theme="dark">のように属性を付け、CSSセレクタでこの属性がある場合は端末設定より優先させます
  3. 選択内容をlocalStorageに保存する: 再訪問時も同じ設定が復元されるようにします
  4. ちらつき防止スクリプトを仕込む: 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点だけで、逆に言えばそこさえ乗り越えれば残りは素直に実装が進みます。このブログでも実際にこの構成のまま運用しています。

この記事を書いた人

ラボ管理人

AIツール・ノーコード・Next.jsで実際に手を動かしながら、AIツクリラボを運営しています。

運営者情報を見る →