ダークモードのちらつき(FOUC)をリロード時に消す方法(インラインscript + localStorage)

概要

今回はダークモード対応のWebアプリをリロードする時に発生する「ライトテーマからダークテーマへの一瞬チカッっと現象」を消す方法について紹介していきます。

ダークテーマで運用しているのに、リロードすると一瞬「白い画面が一瞬パッと光って、それからダークに戻る」あの嫌なフラッシュって触ってみると違和感がありますよね…

体験品質をどん底まで下げるので、私が作ったcopype.shinpinoshi.comこぴぺったりでは最初から対策を入れております。

実装は数行のインラインscriptとCSS変数だけで完結するので、React/Vue 問わず使える超有益な小ネタです。

できるだけわかりやすく説明しているつもりなので、是非見ていってください!

それではやっていきましょう(^^!

目次

なぜフラッシュが起きるのか

ダークモード対応のWebアプリで「ライト→ダーク」のフラッシュが起きる原因は、ブラウザがHTMLをパースして最初の描画を行うタイミングと、ReactがマウントされてDOMにテーマクラスを当てるタイミングの間にズレがあるからです。

時系列で見るとこうなります。

  • ① ブラウザが index.html を取得
  • ② CSSの初期値(=ライトテーマ寄りの色)でファーストペイント
  • ③ JSバンドルが届いて React がマウント
  • ④ React が localStorage を読んで <html>theme-dark クラスを付与
  • ⑤ ダークテーマで再描画

②と⑤の間が「ライトテーマ」の状態で表示されるので、フラッシュして見えます。

軽量なアプリでも 100〜300ms くらいはこの隙間が生まれるので、ユーザーがリロードしたときに毎回チカッと光ります。

解決策:paint前にtheme クラスを当てる

シンプルな解決策は「Reactがマウントされる前に、HTMLパース時点で localStorage を読んでクラスを当てる」ことです。

<head> の中に同期的なインラインscriptを置いて、最初のpaintの前に <html> 要素にtheme クラスを付与します。

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>こぴぺったり</title>

<link rel="stylesheet" href="/styles.css" />

<script>
// Apply persisted theme before paint to avoid a flash.
(function () {
try {
var t = localStorage.getItem("clipapp_theme");
if (t === "light") document.documentElement.classList.add("theme-light");
} catch (e) {}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
  • IIFE(即時実行関数)で localStorage を読む
  • theme-light クラスを <html> に付与
  • try-catch で localStorage が無効な環境(プライベートモード等)でも例外で落ちないようにする

このスクリプトはCSS読み込みの直後・他JSの前に置くのが鉄則です。

CSS変数でテーマを切り替える設計

クラスベースで切り替えるなら、CSS変数を上書きする設計が一番わかりやすくて、一番効果的なのでお勧めです。

styles.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* デフォルト: ダークテーマ */
:root {
--bg: #16131f;
--fg: #f3eaff;
--accent: #7ee7c5;
--border: rgba(200, 182, 226, 0.10);
}

/* ライトテーマ: theme-light クラスが付与されたら上書き */
.theme-light {
--bg: #fbf9ff;
--fg: #2a1d3d;
--accent: #6dd3ad;
--border: rgba(120, 90, 170, 0.10);
}

body {
background: var(--bg);
color: var(--fg);
}

button {
background: var(--accent);
border: 1px solid var(--border);
}

CSS変数なら DOM ツリー全体に一括で配色が伝搬するので、コンポーネントごとに dark: プレフィクスを書く必要がありません。

Tailwind v4 と併用しても問題なく、var(--accent)bg-[var(--accent)] のように呼び出せます。

React 側の同期

paint 前のクラス付与は「初回描画」の対策で、ユーザーがアプリ内のトグルでテーマ切替したときの永続化と再付与はReact側で設定します。

App.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { useEffect, useState } from "react";

const THEME_KEY = "clipapp_theme";

export default function App() {
const [theme, setTheme] = useState(() => {
try {
return localStorage.getItem(THEME_KEY) || "dark";
} catch {
return "dark";
}
});

useEffect(() => {
const html = document.documentElement;
if (theme === "light") {
html.classList.add("theme-light");
} else {
html.classList.remove("theme-light");
}
try {
localStorage.setItem(THEME_KEY, theme);
} catch {}
}, [theme]);

return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
{theme === "light" ? "🌙 ダーク" : "☀️ ライト"}
</button>
);
}
  • useState の初期化関数内で localStorage から読む(再レンダー時の再読み込みを防ぐ)
  • useEffect<html> のクラスと localStorage を同期
  • 例外は握り潰し(プライベートモード対策)

たまにやらかしますが、インラインscriptで使ったキー(clipapp_theme)と React 側のキーは必ず揃えるようにしましょう!
一番重要ですが、たまに忘れて沼ります(- -;

ハマりどころ

キー名がアプリ間で衝突する

localStorage はオリジン単位で共有されるので、theme のような単純なキーは他のアプリやライブラリと衝突することがあります。

  • clipapp_theme のようにアプリ名のプレフィクスを付ける
  • 設定キーをまとめて管理するなら clipapp:settings のような JSON 文字列にしてもOK

サーバーサイドレンダリング (SSR) では document が無い

Next.js などSSR フレームワークでは、初回HTMLにそもそも theme-light クラスを付けられません(サーバーには localStorage が無い)。

  • 解決策1: <html data-theme="dark"> のような属性をデフォルトで付けて、CSP的に許容できる範囲でクライアント側で書き換える
  • 解決策2: Cookie に theme を保存して SSR時に読む
  • 解決策3: SSG/CSR (Vite + React) を選択する(こぴぺったりはこっち)

まぁ当たり前っちゃ当たり前ですが、結構記憶のかなたに忘れがちなので、覚えておきましょう!

ITP(iOS Safari)で localStorage が消える

iOS Safari の Intelligent Tracking Prevention で、7日間アクセスが無いと localStorage が削除される仕様があります。

「フラッシュ」自体は再発しませんが、ユーザーの設定が定期的にリセットされる現象として現れます。

  • 重要な設定は IndexedDB を併用
  • もしくは prefers-color-scheme メディアクエリで OS設定をフォールバックに使う

CSS の transition で別のチカッが起きる

theme切替時に色が「ヌッ」と変わるのが気になる場合、transition: background 200ms を付けたくなりますが、初回ロード時にもこの transition が走ってしまい、別種のチカッが起きます。

1
2
3
4
5
6
7
8
9
:root {
--bg: #16131f;
--fg: #f3eaff;
}

/* 初回は transition 無し、ユーザー操作後だけ有効化 */
body.theme-ready {
transition: background 200ms ease, color 200ms ease;
}

React マウント後に body.classList.add('theme-ready') するワンステップを挟むと回避できます。

まとめ

いかがでしたか?
自分は実のところ結構この事象に悩まされましたが、これで解決できたので、良かったです!
愛情を持って作ったテーマがUXが悪くて使われないのは悲しいので、そういった方の助けになれたら嬉しいです(^^b

ここで紹介した内容をまとめると下記のとおりです!

  • ・フラッシュの原因は「初回paint」と「Reactマウント後」の間のズレ
  • <head> に同期的なインラインscriptを置いて、paint 前に theme クラスを付与
  • ・CSS変数でテーマを切替えると、DOMツリー全体に配色が伝搬する
  • ・React側はトグル時の永続化と再付与のみ担当
  • ・localStorage キーはアプリ名プレフィクスを付けて衝突を防ぐ

数行のインラインscriptで体験品質が一段上がる小ネタです。

ちなみに自分のサイトでもこの技術を実践投入しています。

コピー&ペーストを手助けするために、ワンタップでコピーを行えるシステムです。
メールアドレスなどをコピーする時って、「ダブルクリック → 行選択 → コピーボタン押下」という3ステップ必要ですが、
このサイトを使うとワンタップでできるという優れものです!

良ければ是非使用してみてください。

もちろん完全無料、ダウンロード不要です!

copype.shinpinoshi.comこぴぺったり

この記事で説明したライトテーマに切替えてリロードすると「全くフラッシュしないこと」も是非確認してみてください!

以上となります!
モード切替は愛があるサイトほど作りたくなりますよね!!
それではお疲れさまでした(^^b