On some sites that implement automatic dark mode based on system preference, the change of colors is not immediate and result to flicker. I'll show you how to prevent that so your site visitor does not have to see the light mode if their system setting says so.
For the purpose of showcasing, I'll use React with Vite with vanilla CSS. But, you can use this technique with some styling frameworks like Tailwind CSS and other UI framework too.
First, bootstrap your project directory with Vite create-app
. If you're using other package manager such as yarn
or pnpm
, follow the guide in Vite documentation.
npm init vite
Then choose react
as the framework.
Our focus is to implement automatic dark mode, so we will only modify index.css
, App.css
, and index.html
.
Before that, I'll explain the cause of flicker and how the solution works. The flicker happens when color styles for dark mode is not injected early enough when the site is loaded. Or, the style changes are too much so it takes some time for it to finish.
So, the solution is to inject the theme specific styles as early as possible and make the stylesheet changes as little as possible by using CSS variables.
To inject the theme specific styles as early as possible, we're going to write an inlined IIFE script that will set theming class on html
tag on page load. index.css
file contains global styles and we will add CSS variables here for both .light
and .dark
class respectively.
The CSS variables names need to be identical for both classes. Therefore, local CSS file need not to change various color properties to adapt with <html>
theme class changes.
Let's add the CSS variables:
.light {
--page-bg: hsl(300 20% 99%);
--lo-contrast: hsl(300 20% 99%);
--hi-contrast: hsl(252 4% 44.8%);
}
.dark {
--page-bg: hsl(246 6% 9%);
--lo-contrast: hsl(253 4% 63.7%);
--hi-contrast: hsl(256 6% 93.2%);
}
/*
* Duplicate body selector for the sake of clarity what style is being added.
* Feel free to combine it with the body selector below.
*
* Set body background and default text color here to make sure that
* at least background and text color, as the major colors, are
* immediately set based on the theme.
*/
body {
background: var(--page-bg);
color: var(--hi--contrast);
}
/* CSS from Vite React template. */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
In case you are curious, the colors that I used above is some of Mauve palette taken from @radix-ui/colors. It's awesome by the way!
Then, let's add the script on the head
tag to set the class based on your system preference or user preference saved in localStorage
:
<script>
(() => {
const classList = document.documentElement.classList;
const style = document.documentElement.style;
const dark = window.matchMedia("(prefers-color-scheme: dark)");
const update = () => {
if (
localStorage.theme === "dark" ||
(!localStorage.theme && dark.matches)
) {
classList.add("dark");
style.colorScheme = "dark";
} else {
classList.remove("dark");
style.colorScheme = "light";
}
};
update();
if (dark instanceof EventTarget) {
dark.addEventListener("change", () => {
delete localStorage.theme;
update();
});
} else {
dark.addListener(() => {
delete localStorage.theme;
update();
});
}
window.addEventListener("storage", update);
})();
</script>
Now, your index.html
head
tag will look like this:
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script>
(() => {
const classList = document.documentElement.classList;
const style = document.documentElement.style;
// Returns `MediaQueryList` instance. It will have `matches`
// property if `prefers-color-scheme` is set to 'dark'
const dark = window.matchMedia("(prefers-color-scheme: dark)");
const update = () => {
if (
localStorage.theme === "dark" ||
(!localStorage.theme && dark.matches)
) {
classList.add("dark");
classList.remove("light");
style.colorScheme = "dark";
} else {
classList.add("light");
classList.remove("dark");
style.colorScheme = "light";
}
};
update();
// Re-applied theme class if `prefers-color-scheme` media query is changing.
// Modern browser `MediaQueryList` extends `EventTarget`
if (dark instanceof EventTarget) {
dark.addEventListener("change", () => {
update();
});
} else /* Fallback for old browser */ {
dark.addListener(() => {
update();
});
}
// Re-applied theme class if `localStorage` is being updated.
window.addEventListener("storage", update);
})();
</script>
<title>Vite App</title>
</head>
The script being placed right after charset
and viewport
meta tag to make sure that the theme evaluation is being executed first before anything else.
Aaaand, congratulation! Now, your Vite site dark mode is flicker free!
Next, we will add theme switcher button on top of this. But, that's for the next article or the next iteration of this article. You can also add it yourself! You simply need to create a button that update localStorage.theme
to 'dark' or 'light'.
Please let me know if you find mistakes or better solutions. Thanks for reading!
Source code: https://github.com/izznatsir/vite-auto-theme.
Top comments (0)