chooblarin’s blog

ダークモードに対応する

December 01, 2020

  • #Blog
  • #JavaScript

この記事はフラー Advent Calendar 2020 の1日目の記事です。

先日,Next.jsに移行した本ブログにダークモード (dark mode) 対応を追加しました。

基本の知識

ユーザーの端末がOSで設定している値を参照するには,prefers-color-scheme * というCSSメディアを参照します。CSSの一例を以下に示します。

@media (prefers-color-scheme: dark) {
  /* ダークモードのスタイルを追加する */
  body {
    color: white;
    background: black;
  }
}
@media (prefers-color-scheme: light) {
  /* ライトモードのスタイルを追加する */
  body {
    color: black;
    background: yellow;
  }
}

CSSのスタイルを全て prefers-color-scheme のメディアクエリで切り替えればこれで良いです。しかし実際にはJavaScriptのプログラムで色テーマを制御したい場合も少なくありません。JavaScriptからアクセスするには以下のようにします。

if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
  // ダークモードのときの処理
}

Next.js とダークモード対応

先述の prefers-color-scheme を用いて,以下のような関数を定義したとします。

function getPreferredColorScheme() {
  const mql = window.matchMedia("(prefers-color-scheme: dark)");
  return mql.matches ? "dark" : "light";
}

しかしNext.jsのアプリケーションではReactコンポーネントでこの関数を実行するとエラーになってしまいます。Next.jsはビルド時にプリレンダリングを行うので,Nodeの環境で window オブジェクトに参照しようとして ReferenceError になってしまうのです。

この問題を回避する方法を調べてみたところ,以下のブログ記事にとても詳しくまとまっていました(Gatsbyでの回避策ですが,根本的な問題は同じです)。

また,以下のようなダークモード対応を簡単にするためのReact Hookライブラリも存在します。(これを使用して対応しても良かったのですが,せっかくなので自分で実装してみることにします。)

この2つの実装方法を参考にして,本ブログのダークモード対応を行いました。

flashを防止する

先に述べた通り,Next.jsが静的ページを生成する時点では,"dark" と "light" のどちらのテーマでプリレンダリングを実行すれば良いのかがわかりません。一時的に "light" テーマでページを生成し,クライアント側の情報を取得してからテーマの色をセットするのでは,一瞬正しくない色がチラついて表示されてしまいます。

そこで,以下の手順でこれを回避します。

  • CSS Variablesを使ったスタイルでプリレンダリング
  • クライアントでページがロードされてから即座に実行するスクリプトを定義
  • そのスクリプトで "dark" か "light" を判定してCSS Variablesをセットする

まず最初に以下のようなスタイルを追加します。

body {
  color: var(--color-text);
  background: var(--color-background);
}

次に,カラーテーマを判別するためにクライアント側でのみ実行するスクリプト ColorThemeScript を作成します。

const ColorThemeScript: React.FC = () => (
  <script
    dangerouslySetInnerHTML={{
      __html: `
      (function() {
        var preferDarkQuery = '(prefers-color-scheme: dark)';
        var mql = window.matchMedia(preferDarkQuery);

        ...

        var colorMode = getInitialColorMode();
        setThemeColors(colorMode);
      })();
    `,
    }}
  />
);

このスクリプトは先ほど紹介した donavon/use-dark-mode から拝借します。

そしてNext.jsのカスタムドキュメント (_document) に <ColorThemeScript /> を挿入します。

export default class CustomDocument extends Document {
  render() {
    return (
      <Html>
        <Head />
        <body>
          {/* bodyタグの先頭に挿入する */}
          <ColorThemeScript />
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

クライアント側では ColorThemeScript のスクリプトによって決定した情報をReact.Contextに保存してアクセスできるようにします。この実装は先ほど紹介したブログ記事 "How to add dark mode to a Gatsby site" のコードを参考にしました。

const ColorThemeContext = React.createContext<ColorTheme>(undefined);

const ColorThemeProvider: React.FC = ({ children }) => {
  const [colorMode, setColorMode] = React.useState(undefined);
  React.useEffect(() => {
    const root = window.document.documentElement;
    const initialColorValue = root.style.getPropertyValue(
      "--initial-color-mode"
    );
    setColorMode(initialColorValue);
  }, []);

  const changeColorMode = (mode: string) => {
    ...
  }

  return (
    <ColorThemeContext.Provider value={{ colorMode, changeColorMode }}>
      {children}
    </ColorThemeContext.Provider>
  );
};

ColorThemeProvider はNext.jsのカスタムApp (_app) に追加します。

function App({ Component, pageProps }: AppProps) {
  ...
  return (
    <ColorThemeProvider>
      <Component {...pageProps} />
    </ColorThemeProvider>
  );
}

これで対応は完了です。

まとめ

CSS Variablesを使ってダークモードに対応しました。ユーザーがトグルスイッチで明示的に切り替えたテーマはlocalStorageに保存しており,次回以降のサイト訪問時には優先的に選択されるように実装しています。

最初はlocalStorageの代わりにIndexedDBを使おうと考えたのですが,非同期処理が面倒だったので辞めました。パフォーマンスにはほぼ影響無いと思いますが,実装方法の良いアイディアを見つけたら挑戦してみたいと思います。

余談ですが,ユーザーのOS設定 (prefers-color-scheme で取得できる値) を参照しているのだからUIで切り替える機能は不要だという意見もあるようです。ところがFirefoxのfingerprinting対策オプション * が有効になっていると,この値は常に "light" モードとして上書きされるようで,カラーテーマ切り替えのUIがあっても良いのかも知れません。