Hyoban

Hyoban

Don’t do what you should do, do you want.
x
github
email
telegram
follow

満足のいくダークモード切替ボタンを実装する

それはどのようなものになるでしょうか#

一:外観とインタラクションの観点から:

  1. 1 つのボタンだけで、クリックすることで切り替え、三択のドロップダウンメニューではない
  2. サーバーサイドレンダリングに優しく、ボタンは現在のテーマがダークモードかどうかを直接反映する
  3. ページをリフレッシュしてもちらつかない
  4. 切り替え時にページの色が全体的に遷移し、不一致が生じない

二:処理ロジックの観点から:

  1. ユーザーの好みはブラウザストレージに永続化できる
  2. ユーザーの好みはシステムの好みに無感覚で復元できる

ScreenShot 2024-01-04 19.10.00

私は Jotai を使って実現します。私は Jotai が好きです。

システムの好みの状態を取得する#

Media queriesprefers-color-schemematchMedia を使用して、システムの好みがダークモードかどうかを取得します。

onMount 関数は atom がサブスクライブされたときに実行され、サブスクリプションが解除されたときにその戻り値の関数を実行します。サーバーサイドレンダリングに対応するためにブラウザ環境の判断ロジックを追加します。

function atomSystemDark() {
  const isSystemDarkAtom = atom<boolean | null>(null);

  isSystemDarkAtom.onMount = (set) => {
    if (typeof window === "undefined") return;
    const matcher = window.matchMedia("(prefers-color-scheme: dark)");
    const update = () => {
      set(matcher.matches);
    };
    update();
    matcher.addEventListener("change", update);
    return () => {
      matcher.removeEventListener("change", update);
    };
  };
  return isSystemDarkAtom;
}

いつダークモードにすべきか#

  1. ユーザーの好みがダークモードである
  2. システムの好みがダークモードであり、ユーザーの好みがライトモードでない
type Theme = "system" | "light" | "dark";

function isDarkMode(setting?: Theme | null, isSystemDark?: boolean | null) {
  return setting === "dark" || (!!isSystemDark && setting !== "light");
}

ダークモードの状態の読み取りと切り替え#

  1. ユーザーのテーマの好みとシステムの好みを読み取って、現在がダークモードかどうかを判断します。ユーザーのテーマの好みは atomWithStorage を使用してブラウザに保存します(Fixes 2-1)。
  2. jotai-effect を利用して副作用を処理し、状態を HTML ページに同期させ、ユーザーの好みと現在のシステムの好みが一致する場合、ユーザーの好みをシステムの好みに復元します(Fixes 2-2)。
  3. 切り替えボタンのクリックコールバックはパラメータを受け取らず、現在のユーザーの好みとシステムの好みに基づいてユーザーの好みを更新します。
function atomDark() {
  const isSystemDarkAtom = atomSystemDark();
  const themeAtom = atomWithStorage<Theme>(storageKey, "system");

  const toggleDarkEffect = atomEffect((get, set) => {
    const theme = get(themeAtom);
    const isSystemDark = get(isSystemDarkAtom);
    const isDark = isDarkMode(theme, isSystemDark);
    document.documentElement.classList.toggle("dark", isDark);

    if (
      (theme === "dark" && isSystemDark) ||
      (theme === "light" && !isSystemDark)
    ) {
      set(themeAtom, "system");
    }
  });

  return atom(
    (get) => {
      get(toggleDarkEffect);
      const theme = get(themeAtom);
      const isSystemDark = get(isSystemDarkAtom);
      return isDarkMode(theme, isSystemDark);
    },
    (get, set) => {
      const theme = get(themeAtom);
      const isSystemDark = get(isSystemDarkAtom);
      set(
        themeAtom,
        theme === "system" ? (isSystemDark ? "light" : "dark") : "system"
      );
    }
  );
}

使用可能なフックができました#

直接 atom を使用する代わりに、atomDark のためにカスタムフックを作成する方が良い選択です。なぜなら、Jotai の write 関数はリアクティブではなく、直接 atom を使用すると toggleDark 関数だけが使用される可能性があり、その場合に読み取られる状態は正しくありません。

const isDarkAtom = atomDark();

function useDark() {
  const isDark = useAtomValue(isDarkAtom);
  const toggleDark = useSetAtom(isDarkAtom) as () => void;
  return { isDark, toggleDark };
}

ボタンを作成する#

  1. tailwindcss-iconsLucide を使用して、現在のテーマの状態を示すアイコンを導入します(Fixes 1-1)。
  2. tailwind の Dark Mode サポートを使用して、状態アイコンを正しく表示し、isDark 状態を読み取らずにサーバーサイドレンダリングに優しくします(Fixes 1-2)。
  3. 少しのトランジションアニメーション効果を加えます。
function AppearanceSwitch() {
  const { toggleDark } = useDark();

  return (
    <button onClick={toggleDark} className="flex">
      <div className="i-lucide-sun scale-100 dark:scale-0 transition-transform duration-500 rotate-0 dark:-rotate-90" />
      <div className="i-lucide-moon absolute scale-0 dark:scale-100 transition-transform duration-500 rotate-90 dark:rotate-0" />
    </button>
  );
}

ページのちらつきをどう解決するか?#

ブラウザが読み込んだページのスタイルとユーザーの好みが一致しない場合、ページがちらつき、スタイルが更新されることがあります。テーマを正しくするために、ページが読み込まれる前にスクリプトを注入する必要があります。(Fixes 1-3)

Vite を使用している場合は、index.html にスクリプトを注入できます:

<script>
  !(function () {
    var e =
        window.matchMedia &&
        window.matchMedia("(prefers-color-scheme: dark)").matches,
      t = localStorage.getItem("use-dark") || '"system"';
    ('"dark"' === t || (e && '"light"' !== t)) &&
      document.documentElement.classList.toggle("dark", !0);
  })();
</script>

Next.js を使用している場合は、dangerouslySetInnerHTML を使用してスクリプトを注入できます。特に、クライアントでの水和時に React が警告を無視するために suppressHydrationWarning を使用する必要があります。なぜなら、クライアントで html ノードの className を切り替えたため、サーバーサイドレンダリングの結果と一致しない可能性があるからです。

function ThemeProvider({ children }: { children: React.ReactNode }) {
  return (
    <>
      <script
        dangerouslySetInnerHTML={{
          __html: "here",
        }}
      ></script>
      {children}
    </>
  );
}

function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html suppressHydrationWarning>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

切り替え時にトランジションを無効にする#

Disable transitions on theme toggle という記事では、これを行う理由が非常に詳細に説明されています。なぜなら、切り替え時に一部のコンポーネントの色の遷移とページテーマの遷移のリズムが一致しないことを望まないからです。(Fixes 1-4)

transition demo

これは素晴らしいですが、私たちのテーマ切り替えボタンは transition を使用しているため、一部のコンポーネントにホワイトリストを設定できる必要があります。CSS の :not 擬似クラスを使用して実現できます。

/**
 * credit: https://github.com/pacocoursey/next-themes/blob/cd67bfa20ef6ea78a814d65625c530baae4075ef/packages/next-themes/src/index.tsx#L285
 */
export function disableAnimation(disableTransitionExclude: string[] = []) {
  const css = document.createElement("style");
  css.append(
    document.createTextNode(
      `
*${disableTransitionExclude.map((s) => `:not(${s})`).join("")} {
  -webkit-transition: none !important;
  -moz-transition: none !important;
  -o-transition: none !important;
  -ms-transition: none !important;
  transition: none !important;
}
      `
    )
  );
  document.head.append(css);

  return () => {
    // Force restyle
    (() => window.getComputedStyle(document.body))();

    // Wait for next tick before removing
    setTimeout(() => {
      css.remove();
    }, 1);
  };
}

最後に#

上記の問題を処理した後、私は満足のいくダークモード切り替えボタンを持つことができました。私はそれを npm に公開しましたので、直接使用できます。GitHub で完全なコードと例を確認できます。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。