DEV Community

Cover image for Dark Mode in 3 Lines of CSS and Other Adventures
Mads Stoumann
Mads Stoumann

Posted on • Edited on

Dark Mode in 3 Lines of CSS and Other Adventures

Dark Mode is a design trend where the color scheme of a website is changed to a dark background with light-colored text and elements. It's also referred to as Night Mode or Dark Theme. The purpose of Dark Mode is to reduce eye strain in low light environments, conserve battery life on mobile devices, and create a sleek and modern aesthetic.

Many popular websites and applications now offer a Dark Mode option — here’s TailwindCSS:

Tailwind Color Scheme Selector

If you’re a developer, you most likely already know how to toggle Dark Mode in Dev Tools (for those who don’t know, go to the “rendering” tab!):

Dev Tools Color Scheme Selector

If you want to toggle on Dark Mode for your operating system (and thus all apps supporting Dark Mode), go to System Settings. On a Mac, you‘ll find it under System Settings > Appearance:

MacOS Toggle Dark Mode

There are a lot of tutorials out there already on how to implement dark mode for a webiste.

This tutorial will look into different approaches, including some rad new CSS-features, that’ll allow you to ditch JavaScript.

Ready? Let’s get started.


Dark Mode using System Colors

First, we’ll create a simple HTML-document with a headline:



<body>
  <h1>Hello Darkness, My Old Friend</h1>
</body>


Enter fullscreen mode Exit fullscreen mode

In a stylesheet, we’ll add:



body {
  color-scheme: light dark;
}


Enter fullscreen mode Exit fullscreen mode

This will tell the browser, that our document can accept both a light and a dark color-scheme.

If you run this snippett in a browser, even with Dark Mode on, it will be … light.

Your browser UI will be dark, though.

Light Dark

That’s because the user-agent-stylesheet does not have any default colors set up.

We can fix that quickly, by using System Colors:



body {
  background-color: Canvas;
  color: CanvasText;
  color-scheme: light dark;
}


Enter fullscreen mode Exit fullscreen mode

Let’s check our snippet again:

Dark Mode with System Colors

Much better! Dark Mode for your entire website in 3 lines of CSS!

Let’s dive a bit more into System Colors. From the specs:

In general, the <system-color> keywords reflect default color choices made by the user, the browser, or the OS. They are typically used in the browser default stylesheet, for this reason.

Here’s a Light Mode-demo, showing the available System Colors in Safari:

System Colors

If we switch to Dark Mode, some colors are changed completely (like Canvas and CanvasText as we’ve already encountered), while others are only slightly changed:

System Colors in Dark Mode

Using System Colors for Dark Mode is a bit of a simplified Dark Mode-experience.

Yes, it will work — but pure black and white is a bit boring.

We can spice it up using color-mix in CSS. We can mix a bit of CanvasText (black or white) into Canvas (white or black) for the background-color, and the opposite for color:



body {
  background-color: color-mix(in srgb, Canvas, CanvasText 2.5%);
  color: color-mix(in srgb, CanvasText, Canvas 15%);
}


Enter fullscreen mode Exit fullscreen mode

This results in a “softer” look:

Softer Dark Mode

Deducting saturation from a color, is a widely used way to make variations of colors in Dark Mode.

Using relative colors in CSS, we can do exactly that:



background: hsl(from ActiveText h calc(s - 30%) l);


Enter fullscreen mode Exit fullscreen mode

Unfortunately, relative colors don’t work with System Colors in any browsers — yet!

NOTE: System Colors can be overwritten with forced colors (although used rarely) — so don’t rely too much on this technique.

Let’s move on to another technique, that’ll allow us to fine-control our Dark Mode colors.


Using a prefers-color-scheme media-query

To specify specific colors for light and dark mode, I recommend using CSS Custom Properties, and then update these using a prefers-color-scheme media-query.

With Light Mode as our default, let’s add the colors to a :where(body)-section, to separate them from our regular body-styles:



/* Properties */
:where(body) {
  --background-color: #FFF;
  --text-color: #222;
}
body {
  background-color: var(--background-color);
  color: var(--text-color);
}


Enter fullscreen mode Exit fullscreen mode

Then, for Dark Mode, we’ll simply update these colors-properties:



@media (prefers-color-scheme: dark) {
  :where(body) {
    --background-color: hsl(228, 5%, 15%);
    --text-color: hsl(228, 5%, 80%);
  }
}


Enter fullscreen mode Exit fullscreen mode

Let’s check it out:

Dark Mode with media-query

But what if we want the users to choose which version of our website they want, indepently of the System Settings?

They might prefer a system set to Dark Mode, but our website in Light Mode.

Let’s create a toggler!


Creating a color-scheme toggler

Using JavaScript

If you go to a site like TailwindCSS, you’ll notice that a dark-class is added to the html-node when you select ”dark” from the color-scheme-toggler. This is done with JavaScript:



window.matchMedia('(prefers-color-scheme: dark)').matches)) {
  document.documentElement.classList.add('dark')
} else {
  document.documentElement.classList.remove('dark')
}


Enter fullscreen mode Exit fullscreen mode

Open Props is using a similar method, but updating a data-theme-attribute instead, and then defining properties in two blocks:



[data-theme=light] {
  --nav-icon: var(--gray-7); /* etc */
}
[data-theme=dark] {
  --nav-icon: var(--gray-5); /* etc */
}


Enter fullscreen mode Exit fullscreen mode

Using CSS

Using some brand new CSS-techniques, we can create a toggler without JavaScript.

We’ll create a toggler with 3 states:

  • Light (forced)
  • Auto (system default, could be either light or dark)
  • Dark (forced)

First, some basic markup:



<fieldset>
  <label>
    <input type="radio" name="color-scheme" id="color-scheme-light" value="0">
    Light
  </label>
  <label>
    <input type="radio" name="color-scheme" value="auto" checked>
    Auto
  </label>
  <label>
    <input type="radio" name="color-scheme" id="color-scheme-dark" value="1">
    Dark
  </label>
</fieldset>


Enter fullscreen mode Exit fullscreen mode

After adding some basic styles (see the Codepen-demo below), it renders like this:

Basic Dark Mode Toggler

We’ll add a --darkmode-property and container-type to the html-element:



html {
  --darkmode: 0;
  container-name: root;
  container-type: normal;
}


Enter fullscreen mode Exit fullscreen mode

We’ll be using @container style()-queries, so we need to set the node as a “container”.

Since we don’t want to observe inline-size-changes, we just add the value normal.

If the user picks a “forced” value, we’ll update --darkmode:



html:has(#color-scheme-light:checked) { --darkmode: 0; }
html:has(#color-scheme-dark:checked) { --darkmode: 1; }


Enter fullscreen mode Exit fullscreen mode

And finally, we’ll use a container style()-query to check, if --darkmode is set to 1:



@container root style(--darkmode: 1) {
  body {
    --background-color: hsl(228, 5%, 15%);
    --text-color: hsl(228, 5%, 80%);
  }
}


Enter fullscreen mode Exit fullscreen mode

NOTE: @container style()-queries only works behind-a-flag in Chrome at the moment, it’s early days, so do not use in production.

Now, after selecting “Dark”, our toggler (and page) looks like this:

Toggler in Dark Mode

Simple and effective!


Storing state

If we want to store the users choice, I’m afraid we need a bit of JavaScript!

First, let’s add an identifier to our <fieldset>:



<fieldset id="colorScheme">


Enter fullscreen mode Exit fullscreen mode

Then, in JavaScript:



const colorScheme = document.getElementById('colorScheme')
colorScheme.addEventListener('change', event => {
  localStorage.setItem('color-scheme', event.target.value)
})


Enter fullscreen mode Exit fullscreen mode

Now, all we have to do is setting the property to the localStorage-value when the document has been loaded:



window.addEventListener("load", event => {
  const scheme = localStorage.getItem('color-scheme') || 'auto'
  if (scheme) {
    document.documentElement.style.setProperty('--darkmode', scheme)
  }
})


Enter fullscreen mode Exit fullscreen mode

To select the correct mode in the toggler, add this to the if-block:



const selected = [...colorScheme.elements].filter(element => element.value === scheme)
if (selected) selected[0].checked = true;


Enter fullscreen mode Exit fullscreen mode

Demos

Toggler

System Colors


Photo-credit: Sora Shimazaki

Top comments (20)

Collapse
 
l10nelw profile image
Lionel Wong • Edited
<fieldset id="colorScheme">
Enter fullscreen mode Exit fullscreen mode
colorScheme.addEventListener(...)
Enter fullscreen mode Exit fullscreen mode

What is the general consensus in the community about referencing elements like this? As opposed to:

document.getElementById('colorScheme').addEventListener(...)
Enter fullscreen mode Exit fullscreen mode

Isn't it considered unsafe and error-prone? I don't really remember, it's rarely seen in the wild, perhaps for a reason.

Collapse
 
madsstoumann profile image
Mads Stoumann

When I write example-code for articles, I tend to omit selectors, because I want to focus on what the code does, rather than how to select a Node in the DOM.
I assume most of Dev.to's readers knows how to do that anyway, and might want to use their own selectors (class-based, attribute-based, something else?) instead of just copy/pasting the code.

But I could be wrong! So thank you for your feedback — I've added this to the article:

const colorScheme = document.getElementById('colorScheme');
Enter fullscreen mode Exit fullscreen mode
Collapse
 
wadecodez profile image
Wade Zimmerman

It's best practice to use document.getElementById over the global variable because defining a local variable colorScheme would take precedence. It's also annoying to debug these global variables because they are not explicitly defined. When searching for something like colorScheme = you won't find anything, and finding something like colorScheme.addEventListener tells you nothing about the type of variable. However, finding document.getElementById tells you it's a DOM element.

Collapse
 
adam_cyclones profile image
Adam Crockett 🌀

I vote that implicit code is dangerous code and explicit code is safe code and there is no grey area 🫣

Collapse
 
madsstoumann profile image
Mads Stoumann

(see updated feedback above)

Collapse
 
ant_f_dev profile image
Anthony Fung

Very cool.

I've used this scheme of system themed colours when programming Windows desktop apps.

I didn't know it existed in CSS though - thanks!

Collapse
 
madsstoumann profile image
Mads Stoumann

👍🏻

Collapse
 
iamhectorsosa profile image
Hector Sosa

Very detailed post! Great share! @madsstoumann Framing these screenshots would've been chef's kiss. I've built a simple OSS tool to help with screenshots with ease. Give it a shot and let me know what you think.

github.com/ekqt/screenshot

Collapse
 
madsstoumann profile image
Mads Stoumann

Cool!

Collapse
 
noruwa profile image
Obaseki Noruwa

Love this ❤️, Thanks for sharing.

Collapse
 
madsstoumann profile image
Mads Stoumann

👍🏻

Collapse
 
devgancode profile image
Ganesh Patil

This is something I'm for, Thanks for sharing! 🚀

Collapse
 
madsstoumann profile image
Mads Stoumann

👍🏻

Collapse
 
sehgalspandan profile image
Spandan Sehgal

Amazing code snippet.. thanks for telling.

You just saved a lot of time ⏲️ 😌

Collapse
 
madsstoumann profile image
Mads Stoumann

👍🏻

Collapse
 
slowcodersloth profile image
Slow Coder Sloth

I love it.

Collapse
 
madsstoumann profile image
Mads Stoumann

👍🏻

Collapse
 
ruthmoog profile image
ruthmoog

Great tips, this was exactly what I've been looking for! 👏

Collapse
 
madsstoumann profile image
Mads Stoumann

👍🏻

Collapse
 
louwers profile image
Bart Louwers

If statement missing here:

window.matchMedia('(prefers-color-scheme: dark)').matches)) {
Enter fullscreen mode Exit fullscreen mode