ダークモードのちらつき(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 |
|
- IIFE(即時実行関数)で localStorage を読む
theme-lightクラスを<html>に付与try-catchで localStorage が無効な環境(プライベートモード等)でも例外で落ちないようにする
このスクリプトはCSS読み込みの直後・他JSの前に置くのが鉄則です。
CSS変数でテーマを切り替える設計
クラスベースで切り替えるなら、CSS変数を上書きする設計が一番わかりやすくて、一番効果的なのでお勧めです。
styles.css
1 | /* デフォルト: ダークテーマ */ |
CSS変数なら DOM ツリー全体に一括で配色が伝搬するので、コンポーネントごとに dark: プレフィクスを書く必要がありません。
Tailwind v4 と併用しても問題なく、var(--accent) を bg-[var(--accent)] のように呼び出せます。
React 側の同期
paint 前のクラス付与は「初回描画」の対策で、ユーザーがアプリ内のトグルでテーマ切替したときの永続化と再付与はReact側で設定します。
App.jsx
1 | import { useEffect, useState } from "react"; |
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 | :root { |
React マウント後に body.classList.add('theme-ready') するワンステップを挟むと回避できます。
まとめ
いかがでしたか?
自分は実のところ結構この事象に悩まされましたが、これで解決できたので、良かったです!
愛情を持って作ったテーマがUXが悪くて使われないのは悲しいので、そういった方の助けになれたら嬉しいです(^^b
ここで紹介した内容をまとめると下記のとおりです!
- ・フラッシュの原因は「初回paint」と「Reactマウント後」の間のズレ
- ・
<head>に同期的なインラインscriptを置いて、paint 前に theme クラスを付与 - ・CSS変数でテーマを切替えると、DOMツリー全体に配色が伝搬する
- ・React側はトグル時の永続化と再付与のみ担当
- ・localStorage キーはアプリ名プレフィクスを付けて衝突を防ぐ
数行のインラインscriptで体験品質が一段上がる小ネタです。
ちなみに自分のサイトでもこの技術を実践投入しています。
コピー&ペーストを手助けするために、ワンタップでコピーを行えるシステムです。
メールアドレスなどをコピーする時って、「ダブルクリック → 行選択 → コピーボタン押下」という3ステップ必要ですが、
このサイトを使うとワンタップでできるという優れものです!
良ければ是非使用してみてください。
もちろん完全無料、ダウンロード不要です!
この記事で説明したライトテーマに切替えてリロードすると「全くフラッシュしないこと」も是非確認してみてください!
以上となります!
モード切替は愛があるサイトほど作りたくなりますよね!!
それではお疲れさまでした(^^b