Hyoban

Hyoban

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

Implement a satisfactory dark mode toggle button.

What will it look like#

  1. In terms of appearance and interaction:

    1. There is only one button, which is toggled by clicking, instead of a three-option dropdown menu.
    2. Server-side rendering friendly, the button can directly reflect whether the current theme is dark.
    3. There will be no flickering when the page is refreshed.
    4. When switching, the overall color of the page transitions smoothly without inconsistencies.
  2. In terms of processing logic:

    1. User preferences can be persisted in the browser storage.
    2. User preferences can be seamlessly restored to system preferences.

ScreenShot 2024-01-04 19.10.00

I will use Jotai to implement it, I like Jotai.

Get system preference status#

Use Media queries prefers-color-scheme and matchMedia to determine whether the system preference is dark.

onMount function will be executed when the atom is subscribed and executed when the subscription is canceled. Add logic to determine the browser environment to be compatible with server-side rendering.

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;
}

When should it be dark#

  1. User preference is dark.
  2. System preference is dark and user preference is not light.
type Theme = "system" | "light" | "dark";

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

Reading and switching dark mode#

  1. Read the user's theme preference and system preference to determine if the current mode is dark. Store the user's theme preference in the browser using atomWithStorage (Fixes 2-1).
  2. Use jotai-effect to handle side effects, synchronize the state to the HTML page, and restore the user's preference to the system preference when the user preference and the current system preference are consistent (Fixes 2-2).
  3. The click callback of the toggle button does not accept parameters, and the user preference is updated based on the current user preference and system preference.
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"
      );
    }
  );
}

We have a usable hook now#

Creating a custom hook for atomDark would be a better choice than using atom directly. Because the write function of Jotai does not have reactivity, using atom directly may only use the toggleDark function, and the state read will be incorrect.

const isDarkAtom = atomDark();

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

Let's add a button#

  1. Use tailwindcss-icons and Lucide to import icons to represent the current theme status (Fixes 1-1).
  2. Use tailwind's Dark Mode support to display the status icon correctly without reading the isDark state, making it server-side rendering friendly (Fixes 1-2).
  3. Add some transition animation effects.
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>
  );
}

How to solve page flickering?#

When the page style loaded by the browser is inconsistent with the user preference, there will be flickering and style updates. We need to inject a script before the page loads to ensure the correct theme (Fixes 1-3).

If you are using Vite, you can inject the script in 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>

If you are using Next.js, you can use dangerouslySetInnerHTML to inject the script. It is worth mentioning that we need to use suppressHydrationWarning to ignore the warning from React during client-side hydration. Because we are switching the className of the html node on the client side, which may be inconsistent with the server-side rendering result.

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#

In the article Disable transitions on theme toggle, this approach is explained in detail. Because we don't want the color transitions of some components and the page theme to have inconsistent rhythms during the toggle (Fixes 1-4).

transition demo

This is great, but our theme toggle button uses transition, and we need to whitelist some components. We can use the css :not pseudo-class to achieve this.

/**
 * 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);
  };
}

Finally#

After addressing the above issues, I have a satisfactory dark mode toggle button. I have published it on npm, so you can use it directly. You can view the complete code and examples on GitHub.

A Jōtai utility package for toggling dark mode

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.