Coding: Dark mode

Don’t do it the dark way

Jérôme Beau
7 min readJun 4, 2024

Dark mode has been around for more than a decade now. It was first introduced in desktop OS: MacOS X Yosemite in June 2014, then generalized and in Windows 10 in 2016.

Display options in MacOS system settings

That year and the year later, the next logical feature was shipped: a “Night Shift” option to automatically switch between dark and light modes, depending on the hour of the day, that is, the expected lighting of the environment.

The night shift MacOS feature allows to specify switch at manual or automated (sunset/sunrise) times.

Finally, in 2019, it became available in mobile OS: iOS 13 and Android 10 .

Then, the browsers.

Why?

A number of motivations have been stated to use a dark mode:

  • ocular fatigue: There is a myth about dark mode helping reduce eye strain. As often, the truth is rather that “it depends” on the screen environment, since the real goal is to avoid too much contrast with it. So if you’re in a dark room or about to go to sleep, dark mode and “night shift” colors will be better. But if you’re in a lighted office, light mode will be more comfortable to your eyes (and is usually providing a quicker and easier reading experience).
  • blue light: when using dark mode, you limit the amount of “blue light”, which is reputed to prevent ease of sleep. “Night shift” OS settings have been added to fix this, by converting OS colors to warmer tones deprieved of that infamous blue light.
  • energy consumption: OLED screens can consume three to six times less energy displaying dark background than light ones, which is reputed to improve your screen’s lifetime. However, this will not be the case using “AMOLED Black” color schemes for instance.
  • geek trend/hype: At the beginning, software engineers typed a lot on commands on a console, looking at CRT screens of a greenish dark hue. Today, a cultural reminiscence of this era drives some developers to feel like pro hackers when typing on dark screens (sometimes using a old-but-fashioned clicking keyboard).

Speaking about culture, note that an effort to use more racially-neutral terms in technology may have contributed to using “dark vs light” terms instead of “black vs white”.

What

As we said, dark mode was in the OS years before it landed in browsers.

Before this, websites started to feature their own independent dark mode switches:

A typical light/dark mode switch.

thus leading:

  • specifying your preferred mode for each site, manually;
  • allowing dark mode settings which are out of sync with your OS preferences, especially according to “night switch” settings.
A more rational labelling of the light/dark switch, clarifying which one should be used, depending on the amount of surrounding light in the day. But then, couldn’t this be automated?

With the advent of the prefers-color-scheme: dark media query, one would have thought that websites would migrate to stick to it instead of spending time/money in reproducing the OS feature (in a worse, unsynchronized way).

Reinventing the wheel

That did not happen. Instead, websites either:

  • just didn’t use it;
  • added a third “system” option to sync with it. This could be either seen as an improvement or, in the opposite way, allowing the silly option of not applying your own system settings.
App settings allowing to select dark/light or system setting (dark at the time of the video)

So you get the point: the only sensible option is to sync with OS settings, and get rid of any other app-specific dark/light settings. Just like language settings, there is no reason why you would want to use another setting in an specific app or another (and yes, language switches are as dumb as dark mode switches in apps and websites).

How

So we now know that all we have to do is to stick with OS settings. How can we do this in a web app?

As stated above, the prefers-color-scheme media query allows to apply specific styling when light or dark mode is enabled.

Colors

Such a directive can then be used to change “light” and “dark” colors. Should it have been available before 2014, this would have led to duplicating a lot of code styles:

body {
color: black;
background-color: white;
}

.button { border-color: black; }
.title {
color: white;
background-color: black;
}

@media (prefers-color-scheme: dark) {
body {
color: white;
background-color: black;
}
.some-class { border-color: white; }
.title {
color: black;
border-color: white;
}
}

Hopefully, since then, we can use CSS Custom properties to just change the values:

:root {
--color: black;
--background-color: white;
}

body {
color: var(--color);
background-color: var(--background-color);
}
.button { border-color: var(--color); }
.title {
color: var(--background-color);
background-color: var(-color);
}

@media (prefers-color-scheme: dark) {
:root {
--color: white;
--background-color: black;
}
}

You can view the color variables declared in the :root selectors above as your light and dark palettes (but of course custom properties are not limited to color values). Keep in mind that both those color sets must be accessible as it may impact your website ranking.

You could even use currentcolor instead of some --color variables, and reduce the code, thanks to the fact that currentcolor may be inherited.

But this is is not the most synthetic form of a dark/light style. Let’s see how theming can help.

Theme

With the code above, your web page will now sync its colors with the OS settings, including night shift.

But what about native components like input fields, buttons, scroll bars, etc.?

Enforcing dark mode custom colors does not impact “native” web components (top), unless you specify a dark color scheme (bottom)

They will remain the same, unless you add a color-scheme property to ask those native components to switch to their native dark colors:

@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
}
}

Finally, you can alternatively switch to another light dark color scheme, which provides built-in support for light and dark values:

:root {
color-scheme: light dark;
}

body {
color: light-dark(black, white);
background-color: light-dark(white, black);
}
.button { border-color: light-dark(black, white); }
.title {
color: light-dark(white, black);
background-color: light-dark(black, white);
}

This schema will allow you to both request native components to use their relevant native scheme, and to specify both color values in a single line.

Note that, while color constants are used again in the example above, you can perfectly keep on using custom properties instead. This would be a good choice, actually, to keep you from replacing strings instead of just changing variable values.

Images

Another common need when implementing dark mode is the ability to change image: even with some background transparency, the non-transparent colors will rarely fit well in both modes.

To do so, a convenient way is to use background images. Why, because they can be specified as CSS properties, and so can change depending on media queries:

.logo {
height: 64px;
width: 64px;
background-size: contain;
background-image: url('assets/logo.png');
}

@media (prefers-color-scheme: dark) {
.logo {
background-image: url('assets/logo_dark.png');
}
}

Now the images will switch as soon as dark or light mode will be selected, or when night switch activates.

If you can’t use a background image, you’ll probably have to use code to set proper image elements in the DOM (see below).

Code

Sometimes you would like to do more than switching colors or background images depending on the theme: you would like to detect mode change from the webapp code in order to execute appropriate code.

Hopefully, media queries are also available from JavaScript, and their result can be queried:

const preferDark = window.matchMedia("(prefers-color-scheme: dark)")
const img = preferDark.matches ? darkImage : lightImage

However this will check the OS dark mode preference only once, which will not be enough to get sync with a Night Shift-triggered change. To make sure you’re always sync with the user settings, you need to listen to live changes of it, through the relevant listener:

setDarkMode (darkMode) => img = darkMode ? darkImage : lightImage

const preferDark = window.matchMedia("(prefers-color-scheme: dark)")
preferDark.addEventListener("change", event => setDarkMode(event.matches))
setDarkMode(preferDark.matches) // Initial setting

Conclusion

Dark or light is neither good or bad, as it depends on environment and user preferences. Dark mode support in web apps should rely on media queries, in order to honor such user preferences (including scheduled ones like Night Shift).

--

--