Skip to main content

All you need for a proper dark theme

· 5 min read
Alex
Web developer
Translations

Three states, no flash, reactive. Test it at zwyx.github.io/proper-dark-theme


Although implementing dark theme manually might sound like reinventing the wheel, it's actually easy and there are good reasons to do it:

  • dark theme libraries depend on a specific stack,
  • UI/component libraries often — incredibly often, based on the dozen I've tested — don't correctly implement the following requirements.

Three requirements for a proper dark theme

Three states

The user must be able to choose same as device (which should be the default), light, or dark.

No flash

If the current theme is dark, the page must not display a white background while it loads (very annoying at night, and possibly problematic for users with vision disabilities).

Reactive

Changing the theme — including the device's theme, when same as device is selected — should be instantly effective on the page, without requiring to refresh it.

tip

In order to have these features, and not being dependent on a specific stack, I found that the best it to implement dark theme manually.

Once we know how to do it, we can reuse it everywhere. Instead of installing it as a library, we copy and paste it. We « own the code », which is a philosophy I first discovered with Shadcn UI.

Implementation

Our way of doing it will be:

  • have JavaScript applying a class light or dark to the root HTML element, and
  • have different CSS variables depending on this class.
info

A demonstration of this implementation is deployed here, and its code is present here.

The CSS

CSS variables make it easy to have our app reactive: changing the class on the root HTML element instantly change all the color in the website, no refresh necessary.

:root {
/* variables for light theme */
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
}

:root.dark {
/* variables for dark theme */
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
}

The HTML

To prevent the flash, we want to apply the light or dark class as soon as possible. This is what this small block of JavaScript, to be placed in the head of our HTML, does:

<meta name="theme-color" content="#020817" />

<script>
// Set the theme's class as soon as possible to prevent a flash of the wrong theme

var lightThemeName = "light";
var darkThemeName = "dark";
var storedTheme = localStorage.getItem("theme");
var theme;

if (storedTheme === lightThemeName || storedTheme === darkThemeName) {
theme = storedTheme;
} else if (matchMedia("(prefers-color-scheme: dark)").matches) {
theme = darkThemeName;
} else {
theme = lightThemeName;
}

document.documentElement.classList.add(theme);

if (theme === lightThemeName) {
document
.querySelector('meta[name="theme-color"]')
?.setAttribute("content", "#ffffff");
}
</script>

The theme-color meta tag is optional, it's useful mainly if our app is a PWA. When using it, we want to keep it in sync with our theme color.

The JS

The JS has to maintain two state variables:

  • the user preference: light, dark, or same as devices
  • the currently displayed theme:
    • light, when:
      • the user preference is light
      • the user preference is same as device and the device's theme is light
    • dark, when:
      • the user preference is dark
      • the user preference is same as device and the device's theme is dark

To do that, the JS has to:

  • react to changes of the user's theme preference,
  • store it in local storage,
  • listen for changes of the system's theme, which is done by adding an event listener to a media query:
matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", ()=>{ ... });
  • update the HTML root element's class according to the user preference and the system's theme.

I won't include the rest of the code here, instead I invite you to have a look at the implementation made with TypeScript and React for the demo project.

Sidenote

I consider this three-state implementation to be the best at the moment, but I hope that it won't be necessary in the future.

When all operating systems and all web browsers will implement dark theme seamlessly, then offering to the user the possibility to change the theme for a particular website might start to be seen as unnecessary. At least, the setting could be buried in a dialog box instead of being directly available on the top right of the page.

Bonus: with less JavaScript

Here's another way of defining the CSS variables, which greatly reduces the amount of JS required. We use a media query to apply the dark theme when it's the system preference and the user hasn't selected the light theme.

:root {
/* variables for light theme */
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
}

:root.dark {
/* variables for dark theme */
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
}

@media only screen and (prefers-color-scheme: dark) {
:root:not(.light) {
/* variables for dark theme */
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
}
}

By doing that, the JS doesn't have to listen for changes of the system's theme anymore.

This is how I used to do it. However, it has a few drawbacks:

  • we have to duplicate the declaration of the dark theme's variables,
  • the app cannot know which theme is currently displayed when the user preference is same as device (Tailwind's dark: selector, for instance, doesn't work).