このポートフォリオサイト(shu-web-portfolio)で実際に実装した方法をベースにまとめる。
ブラウザだけで完結するSPAなら「JSでbodyにクラスを付ける」だけでいいが、Next.jsはサーバー側(またはビルド時)にHTMLを生成する。
この「チラつきをどう消すか」が、Next.jsでダークモード対応する上での最大の技術ポイント。
app/globals.css の :root に、ライト/ダーク両方の色をあらかじめ変数として用意する。
:root {
--bg:#fbfcfc; --surface:#f2f6f5; --ink:#141a1f; --muted:#586570; --line:#e4e9ea;
--accent:#0c8f80; --accent-ink:#0a7568;
}
@media (prefers-color-scheme: dark){
:root{ --bg:#0e1217; --surface:#151b22; --ink:#e8eef3; --muted:#8b97a4; --line:#242c36;
--accent:#2fd4c0; --accent-ink:#57e2d1; }
}
:root[data-theme="light"]{ --bg:#fbfcfc; --surface:#f2f6f5; }
:root[data-theme="dark"]{ --bg:#0e1217; --surface:#151b22; }
ポイント:
@media (prefers-color-scheme: dark) は「まだユーザーが手動選択していない=OSの設定に従う」状態:root[data-theme="light"] / :root[data-theme="dark"] は「ユーザーが手動でトグルした」状態。属性セレクタは詳細度でメディアクエリに勝つので、<html data-theme="dark"> を付けるだけでOS設定を上書きできるtailwind.config.ts の theme.extend.colors で登録すれば bg-page のようなユーティリティがそのままテーマ対応になるapp/layout.tsx の head 部分に、Reactのハイドレーションより前に実行される素のscriptタグを埋め込む。
const themeScript = `
(function(){try{
var t = localStorage.getItem('theme');
if (!t) { t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }
document.documentElement.dataset.theme = t;
}catch(e){}})();
`;
このスクリプト文字列を、レイアウトのheadタグの中で「素のscript要素」としてdangerouslySetInnerHTML経由で埋め込む。JSXの構文自体は次の段落に分けて記載する。
なぜこれでチラつかないのか: