User-defined color theme in the browser without the initial flash
When adding dark and light mode to your site, a common approach is to store the theme in localStorage and reading it on the next visit. But our JavaScript usually runs after the page loads, so reading it in JavaScript can cause a flash of the wrong theme—like flashbanging dark mode users with light mode. We can fix this with a small script in the <head>
. But wait—isn’t that a blocking script? Aren’t those bad? Let’s take a quick look at why that’s not always true.
First, here’s the small simple script which you can add into the <head>
of your document:
<html data-theme="dark">
<head>
<!-- all the stuff in your head -->
<script>
(function () {
const theme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
It reads the preferred theme from local storage and adds it as a data attribute to the <html>
element, before the browser renders the site. We can now style our page for light and dark mode based on the html[data-theme=dark]
selector.
Your site will only render after the script ran—so it will only render after the attribute is already set, and there will be no “initial flash of incorrect theme”, or FART.
…and to let users switch the theme, update both localStorage
and the data-theme
attribute.
function toggleTheme() {
const newTheme =
document.documentElement.getAttribute('data-theme') === 'light'
? 'dark'
: 'light'
document.documentElement.setAttribute('data-theme', newTheme)
localStorage.setItem('theme', newTheme)
}
What about cookies? Why not use one? Aren’t blocking scripts bad?
If you have a server and not just a statically hosted site, you can also use a cookie. If you search online for how to avoid the initial flash, you’ll often find this solution: store the theme in a cookie and render the HTML on the server, with the theme already included in the server-rendered HTML.
A cookie gets rid of the blocking script that we use in the example above. A blocking script pauses the browser’s rendering process until the script is fully executed, which can delay the initial page load. We usually want to avoid such delays, which is why blocking scripts are often discouraged. But this doesn’t mean that a blocking script is always worse.
The downside of cookies is that you have to render the HTML on the server for every request, which also takes some time.
Caching is much easier without a cookie, and it significantly speeds up your site. You could even put a CDN in front of your HTML and cache your whole page. You can’t do that with a user-specific cookie. (Unless you’re Valve on a certain unforgettable Christmas morning in 2015.)
The blocking script takes only approximately 0.1ms to run. That’s 100 microseconds. There’s a chance that server-rendering your uncached HTML will take more time—several milliseconds or even seconds, depending on server and network conditions.
Here’s my take on it:
- If you render user-based HTML dynamically on the server anyways, just store the user’s preferred theme in your database, where you already store your user’s data. And then you can render
<html data-theme={user.preferredTheme}>
on the server. It won’t make a difference. No blocking scripts necessary. - If your site is serving content that doesn’t need to be rendered per request for each user, use the approach above with a blocking script on the client. You might want to do some benchmarking to check whether your server or the client is faster, but it’d also be a good idea to consider whether you’ll need caching in the future.
Use the user’s preferred color scheme as the default
In the example above, we use dark
as the default color scheme. If you want to default to the user’s preferred color scheme instead, but still let the user switch the theme, you could change it to something like this:
let theme = localStorage.getItem('theme');
theme ||= window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
document.documentElement.setAttribute('data-theme', theme);
…or you could also add another value system
, as the default. It’s a bit more verbose which can be beneficial in certain situations, but can also make your selectors more complex.
I wish there were a native API
It’s a bummer that we have to fall back to CSS selectors when we want to give a user control over the selected theme. The prefers-color-scheme
media query is neat, but even more so the new light-dark()
CSS color function. We can’t use them with a CSS-selector-based solution.
I like the ability to manually toggle the theme for a specific site. Just because I use dark mode on my system doesn’t mean that I want dark mode on every site. Especially for sites I visit and use a lot.
How about a small native API that allows us to override prefers-color-scheme
for the current site?
// This does not exist
window.setUserPreference('color-scheme', 'dark')
It would persist the user’s selection and let us use those nifty CSS features. The browser could even have a native UI to show the override to a user. It could let a user change or reset their preference directly in the browser’s UI, similar to camera and microphone permissions, without relying on toggles and buttons on the site itself.
A man can dream though… a man can dream.