それはどのようなものになるでしょうか#
一:外観とインタラクションの観点から:
- 1 つのボタンのみで、クリックによって切り替える方式であり、三択のドロップダウンメニューではありません
 - サーバーサイドレンダリングに優しく、ボタンは現在のテーマがダークモードかどうかを直接反映します
 - ページのリフレッシュ時にちらつきが発生しません
 - 切り替え時にページの色が全体的に遷移し、不一致が発生しません
 
二:処理ロジックの観点から:
- ユーザーの好みはブラウザストレージに永続化できます
 - ユーザーの好みはシステムの好みに無感覚に復元できます
 

私は Jotai を使用して実現します。私は Jotai が好きです。
システムの好みの状態を取得する#
Media queries の prefers-color-scheme と matchMedia を使用して、システムの好みがダークモードかどうかを取得します。
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;
}
いつダークモードにすべきか#
- ユーザーの好みがダークモードである
 - システムの好みがダークモードであり、ユーザーの好みがライトモードでない
 
type Theme = "system" | "light" | "dark";
function isDarkMode(setting?: Theme | null, isSystemDark?: boolean | null) {
  return setting === "dark" || (!!isSystemDark && setting !== "light");
}
ダークモードの状態の読み取りと切り替え#
- ユーザーのテーマの好みとシステムの好みを読み取って、現在がダークモードかどうかを判断します。ユーザーのテーマの好みは atomWithStorage を使用してブラウザに保存します(Fixes 2-1)。
 - jotai-effect を利用して副作用を処理し、状態を HTML ページに同期させ、ユーザーの好みと現在のシステムの好みが一致する場合に、ユーザーの好みをシステムの好みに復元します(Fixes 2-2)。
 - 切り替えボタンのクリックコールバックはパラメータを受け取らず、現在のユーザーの好みとシステムの好みに基づいてユーザーの好みを更新します。
 
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 };
}
ボタンを作成しましょう#
- tailwindcss-icons と Lucide を使用して、現在のテーマの状態を示すアイコンを導入します(Fixes 1-1)。
 - tailwind の Dark Mode サポートを使用して、
isDark状態を読み取らずに状態アイコンを正しく表示し、サーバーサイドレンダリングに優しくします(Fixes 1-2)。 - 少しのトランジションアニメーション効果を加えます。
 
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 を使用しているため、一部のコンポーネントにホワイトリストを設定できる必要があります。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 で完全なコードと例を確認できます。
Github Repo not found
The embedded github repo could not be found…