🐥

ReactでCSS variablesでダークモードとライトモードを手軽に切り替える

2021/12/02に公開

こんにちは
最近はダークモード、ライトモードが実装されているアプリケーションやWebサイトが増えましたね。
個人的にはダークモードを利用しているので、利用しているアプリがダークモードに対応されていると見やすいなーと感じます。

僕は今年に入ってアプリを一つリリースしました(宣伝です🙏)。
息子がマインクラフトのスキン制作にハマっていて、既存のいくつかのアプリだと操作が難しそうにしていたのをみて、より簡単なアプローチができそうなアプリを思いついたので作ってみました。
もしスキンを作成したい場合は使ってみてください。
(Switch版は自分で作成したスキンを使うことができないので、スマホ版かJava版で試してみてください)

https://skin-editor.com/

このアプリを作るにあたって、テーマの切り替えが欲しくなりました。
白い色のスキンを作るときには背景を暗く、逆に黒い色のスキンを作る場合には背景を白くしないと視認性が悪く作業がしにくい問題が出たからです。

そういうわけで、どういうことを行ったかをメモ程度に記載しておきます。

やったこと

上記のアプリではReact + Emotion + TypeScriptという構成になっています。
が基本的には別のものでもいけると思います。
テーマがいくつもある場合などはTypeScriptはあると便利です。

もともとはemotionのThemeProviderを使う方向で考えていたのですが、そもそも${({theme}) => theme.base.color}みたいなのを書くのしんどいなー + 型をつけるのがちょっと面倒だった気がしたなーという気持ちがあり、この方法を取ることにしました。
型も付くし非常に簡単です🙆‍♂️

というか今調べたら、2年くらい前にWebCreatorBoxさんがこの方法でダークテーマを切り替える件、記事にしていました😅
https://www.webcreatorbox.com/tech/dark-mode
まぁでも折角ですし、型周りの話もあるのでポストにしました🙏

CSS variablesでカラーを定義

メディアクエリで定義してしまう方法と、bodyになにかアトリビュートをつけて切り替える方法がありますが、bodyにアトリビュートをつけてやるのが取り回しが楽だと思います。
僕はこういうときはクラスもいいのですが、data属性をもたせてやることが多いです。

以下のようなイメージ感じですね。

body[data-theme='light'] {
  --base-color: #191919;
  --base-background: white;
}

body[data-theme='dark'] {
  --base-color: white;
  --base-background: #101010;
}

使うときには

.hoge {
  background: var(--base-background);
  color: var(--base-color);
}

となります。
簡単ですね🙆‍♂️

テーマを設定する方法

そういうわけで、あとはJavaScriptでよしなにやっていきます。
テーマを設定するタイミングは初回表示と切り替えタイミングの2種類になると思うので、切り替えるような関数を書いていこうと思います。

まずは準備ですね。

export type valueOf<T> = T[keyof T];

export const THEME_KEY = {
  light: 'light',
  dark: 'dark',
} as const;
export type ThemeKey = valueOf<typeof THEME_KEY>;

初回設定時は以下のようになります。

export const initTheme = (): void => {
  const body = document.body;
  const colorTheme = globalThis.matchMedia('(prefers-color-scheme: dark)')
    .matches
    ? THEME_KEY.dark
    : THEME_KEY.light;
  body.dataset.theme = colorTheme;
};

切替時はもっと単純に

export const setTheme = (themeKey: ThemeKey): void => {
  document.body.dataset.theme = themeKey;
};

となります。

initThemeは例えばNext.jsの場合は_app.tsxuseEffect()で読んだり、CRAの場合はindex.tsで呼ぶなどするといいと思います。
もちろん、型情報を取っ払って、HTMLにscriptを埋め込むでもいいです。

setThemeはカラー切替のタイミングで発火させてやるといいと思います。

localForagelocalStroageを使ってテーマが維持されるようにする

localForagelocalStroageを使ってテーマが維持されるようにすることもできます。

その場合はこんな感じになります。

export const initTheme = async (): Promise<void> => {
  const body = document.body;
  const colorTheme = globalThis.matchMedia('(prefers-color-scheme: dark)')
    .matches
    ? THEME_KEY.dark
    : THEME_KEY.light;
  body.dataset.theme = colorTheme;
  try {
    const theme = await localForage.getItem<ThemeKey>(THEME_STORAGE_KEY);
    body.dataset.theme = theme ?? colorTheme;
  } catch (error) {
    body.dataset.theme = colorTheme;
  }
};

export const setTheme = async (themeKey: ThemeKey): Promise<void> => {
  document.body.dataset.theme = themeKey;
  await localForage.setItem<ThemeKey>(THEME_STORAGE_KEY, themeKey);
};

https://github.com/localForage/localForage

テーマのカラーを管理する

上記の例だと、カラー数が非常に少ないため特に問題はありませんが、通常カラー等はかなりの数存在することになると思います。
また、ダークモード/ライトモード以外に違うカラー設定のテーマが必要な可能性もあります。
そんな場合はそれぞれのテーマにカラーを設定するのを忘れる、みたいなことが起きてしまいがちですよね。
もし、CSSの値をJS上で管理できるのであれば、TypeScriptの出番です。

type Theme = {
  base: {
    color: string;
    background: string;
  };
};

先にこんな風にThemeを定義してしまいましょう。

const LIGHT_THEME: Theme = {
  base: {
    color: '#191919',
    background: 'white',
  },
};

const DARK_THEME: Theme = {
  base: {
    color: 'white',
    background: '#101010',
  },
};

こうすれば型で縛られているので、追加漏れが出ません。
こうしたカラーを適当に整形してbodyのスタイルに加えてやることもできます。

/** 
 themeの階層が
 type Theme = {
   [namespace: string]: {
     [name: string]: string
   }
 }
 にしているので変換関数を書く
 */
const convertThemeVariables = (colorTheme: Theme): string =>
  Object.entries(colorTheme)
    .flatMap(([nameSpace, colorValues]) => {
      return Object.entries(colorValues).map(
        ([name, value]) => `--${nameSpace}-${name}: ${value};`
      );
    })
    .join('\n');
export const lightThemeCssVariables = convertThemeVariables(LIGHT_THEME);
export const darkThemeCssVariables = convertThemeVariables(DARK_THEME);

あとはglobalStyleなんかで、こんな感じで設定してやれば楽ちんです。
emotionの例です)

export const globalStyles = css`
  body[data-theme='${THEME_KEY.light}'] {
    ${lightThemeCssVariables}
  }

  body[data-theme='${THEME_KEY.dark}'] {
    ${darkThemeCssVariables}
  }
`;

試してないですけど、emotionを使っていない場合でも、styleタグを生成してheadに突っ込めば行けると思います。


const styleElement = document.createElement('style');
styleElement.textContent = `
  body[data-theme='${THEME_KEY.light}'] {
    ${lightThemeCssVariables}
  }

  body[data-theme='${THEME_KEY.dark}'] {
    ${darkThemeCssVariables}
  }
`;

document.head.appendChild(styleElement);

実際に使うときもタイポのことを考えると型で縛りたくなりますね。
そういうわけで、以下のようにすると便利です。

const getCssVariableValue = <N extends string, V extends string>(
  nameSpace: N,
  valueName: V
): `var(--${N}-${V})` => `var(--${nameSpace}-${valueName})` as const;

export const theme: Theme = {
  base: {
    color: getCssVariableValue('base', 'color'),
    background: getCssVariableValue('base', 'background'),
  },
}

使う側では

export const Button = styled.button`
  background: ${theme.base.color};
  color: ${theme.base.background};
`;

のように使うことができます。

themeの切り替え

themeの切替時には、上述したsetTheme関数を使うことになりますが、実装例を簡単に以下に示しておきます。
今回はコンポーネントのuseStateで値を持っていますが、必要であればReduxなりrecoilなりで持つ事になりそうです。


const Component: React.FC<Props> = () => {
  const [themeKey, setThemeKey] = useState<ThemeKey>(
    (document.body.dataset.theme as ThemeKey) ?? THEME_KEY.light
  );

  const handleChange = useCallback(() => {
    const next =
      themeKey === THEME_KEY.light ? THEME_KEY.dark : THEME_KEY.light;
    setThemeKey(next);
    setTheme(next);
  }, [themeKey]);

  const isDark = themeKey === THEME_KEY.light;
  return (
    <div>
      <label>
        テーマ切り替え
        <input
          type="checkbox"
          name="switchTheme"
          id="switchTheme"
          onChange={handleChange}
	  checked={isDark}
        />
      </label>
    </div>
  );
};

簡単ですね。初期値はbodyから取得しています。そして切替時にはstateの更新とテーマの切り替え(setTheme)を呼んでいます。

デモ

というわけで

とりとめのないメモになってしまいましたが、こんな感じでテーマの切り替えができると思います🙆‍♂️

参考

https://epicreact.dev/css-variables/

Discussion