What will it look like#
-
In terms of appearance and interaction:
- There is only one button, which is toggled by clicking, instead of a three-option dropdown menu.
- Server-side rendering friendly, the button can directly reflect whether the current theme is dark.
- No flickering when the page is refreshed.
- Smooth transition of page colors when switching, without inconsistencies.
-
In terms of processing logic:
- User preferences can be persisted in the browser storage.
- User preferences can be seamlessly restored to system preferences.
I will use Jotai to implement it, 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 is executed when the atom is subscribed to, and the returned function is executed when the subscription is canceled. Add logic to check the browser environment to support 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#
- User preference is dark.
- 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 state#
- Read the user's theme preference and system preference to determine if the current mode is dark. Store the user's theme preference using atomWithStorage in the browser (Fixes 2-1).
- 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 matches the current system preference (Fixes 2-2).
- The click callback of the toggle button does not accept parameters, and updates the user preference 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 the atom directly. This is because the write function of Jotai does not have reactivity, and using the atom directly may only use the toggleDark
function, resulting in an incorrect state.
const isDarkAtom = atomDark();
function useDark() {
const isDark = useAtomValue(isDarkAtom);
const toggleDark = useSetAtom(isDarkAtom) as () => void;
return { isDark, toggleDark };
}
Let's add a button#
- Use tailwindcss-icons and Lucide to import icons to represent the current theme status (Fixes 1-1).
- Use tailwind's Dark Mode support to display the status icon correctly without reading the
isDark
state, making it friendly for server-side rendering (Fixes 1-2). - 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 styles loaded by the browser are inconsistent with the user preferences, 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. This is because we are changing the className
of the html
node on the client, 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#
The article Disable transitions on theme toggle explains in detail why we should do this. We don't want the color transitions of some components and the page theme to have inconsistent rhythms during the toggle (Fixes 1-4).
This is great, but our theme toggle button uses transition
, so we need to whitelist some components. We can use the :not
pseudo-class in CSS 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.