Hyoban

Hyoban

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

實現一個滿意的深色模式切換按鈕

它會是什麼樣子#

一:從外觀和互動上來說:

  1. 只有一個按鈕,通過單擊的方式來切換,而不是一個三選的 Dropdown Menu
  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"
      );
    }
  );
}

我們有能用的 hook 了#

相比於直接使用 atom,為 atomDark 創建一個自定義 hook 會是一個更好的選擇。因為 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. 加一點 Transition 動畫效果。
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 來注入腳本。值得一提的是,我們需要使用 suppressHydrationWarning 來忽略 React 在客戶端水合時的警告。因為我們在客戶端切換了 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>
  );
}

在切換時禁用 transition#

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 上查看完整的代碼和示例。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。