Hyoban

Hyoban

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

Implement a satisfactory dark mode toggle button

What Will It Look Like#

  1. From the perspective of appearance and interaction:

  2. There is only one button to toggle by clicking, rather than a three-option Dropdown Menu.

  3. Server-side rendering friendly, the button can directly reflect whether the current theme is dark.

  4. There will be no flickering when the page refreshes.

  5. The overall transition of the page color during switching will be consistent.

  6. From the perspective of processing logic:

  7. User preferences can be persisted to browser storage.

  8. User preferences can be seamlessly restored to system preferences.

ScreenShot 2024-01-04 19.10.00

I will use Jotai to implement this; I like Jotai.

Getting System Preference Status#

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

The onMount function will execute when the atom is subscribed and will execute its returned function when unsubscribed. Add logic to check 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 State#

  1. Read the user's theme preference and system preference to determine if it is currently in dark mode, using atomWithStorage to store user theme preference in the browser (Fixes 2-1).
  2. Use jotai-effect to handle side effects, synchronize state to the HTML page, and restore user preference to system preference when user preference matches current system preference (Fixes 2-2).
  3. The click callback for the toggle button does not receive parameters; it updates user preference based on 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 Usable Hooks Now#

Creating a custom hook for atomDark is a better choice than directly using the atom. Since Jotai's write function is not reactive, directly using the atom may only utilize the toggleDark function, which would result in incorrect state readings.

const isDarkAtom = atomDark();

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

Let's Have a Button#

  1. Use tailwindcss-icons and Lucide to introduce icons representing the current theme state (Fixes 1-1).
  2. Use Tailwind's Dark Mode support to correctly display the state icon without reading the isDark state, making it server-side rendering friendly (Fixes 1-2).
  3. Add a little transition animation effect.
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 styles loaded by the browser are inconsistent with user preferences, page flickering occurs, updating styles. We need to inject a script before the page loads to ensure the theme is correct (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 React's warnings during client hydration. This is because we switched the className of the html node on the client side, which may be inconsistent with the server-rendered 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 Transition During Switching#

The article Disable transitions on theme toggle explains in detail why this is necessary. We do not want the color transitions of some components to be inconsistent with the transition rhythm of the page theme during switching (Fixes 1-4).

transition demo

This is great, but our theme switch button uses transition, so we need to whitelist certain components. This can be achieved using the CSS :not pseudo-class.

/**
 * 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, and you can use it directly. You can view the complete code and examples on GitHub.

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