How To Add Dark Mode Support To Your Website

Posted by
on under

You may have noticed that I have recently added a dark mode to this blog. The default color theme now follows the theme setting in your operating system by default, and you can also select which mode to enable from the top navigation bar.

Light/Dark Modes

Nice, right? I have implemented this feature entirely in the front end, using CSS and a touch of JavaScript. Interested in implementing a similar feature for your own website? In this article I'll show you how I did it.

Introduction to CSS Variables

Being able to switch between light and dark modes (or actually, between two or more color themes that do not necessarily need to be light or dark) means that most of your CSS definitions involving color, such as color, background-color and border will need to be redesigned so that the actual colors can be changed.

There isn't a single way to do this, but using CSS variables is an elegant solution that prevents duplication and keeps your CSS styles readable.

I will show you how to use CSS variables with an example. Below you can see a small web page that was designed without any consideration for color themes. For convenience, I have embedded the CSS definitions in the HTML file. This would work exactly the same with an external .css file.

<doctype !html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Themes Example</title>
    <style>
      body {
        font-family: sans-serif;
        background-color: #e8e8ea;
        color: #333333;
      }
      h1 {
        color: #44446a;
      }
      h2 {
        color: #64648a;
      }
      code {
        font-size: 130%;
        color: #a83030;
        background-color: #d8d8d8;
        padding: 2px;
      }
    </style>
  </head>
  <body>
    <h1>Themes Example</h1>
    <p>This is a paragraph.</p>
    <h2>Level 2 heading</h2>
    <p>Another paragraph with a <code>code</code> word.</p>
  </body>
</html>

You can copy and paste the above HTML into a file on your computer and open it with your web browser if you like, but below you can see how this page looks:

Light Mode

The <style> element in the example page defines the following colors:

  • A light gray page background
  • A default dark gray text color
  • A dark blue text color override for level 1 headings
  • A lighter (though still fairly dark) blue text color override for level 2 headings
  • A custom style for elements written inside <code> ... </code> tags

Since this page was designed without consideration for color themes, it will render exactly as in the screenshot above regardless of what color mode the user's computer is configured to.

As a first step in implementing color themes, let's move the background color of the page to a CSS variable:

    <style>
      :root {
        --page-bg-color: #e8e8ea;
      }
      body {
        font-family: sans-serif;
        background-color: var(--page-bg-color);
        color: #333333;
      }
      ...
    </style>

With this change the page has a CSS variable named --page-bg-color. CSS variables must start with a -- prefix, and can be defined inside any CSS section. A common practice is to put CSS variables under a pseudo-class called :root, which makes them accessible from anywhere in the page. Once a variable is defined, it can be referenced with var(), as you see in the updated background-color definition above.

If you refresh the page after refactoring the background page color into a variable you will not see any difference. You can try changing the value of the variable to see how the background is now controlled by it. Here is a good color to use for the dark mode of the page:

      :root {
        --page-bg-color: #18181a
      }

Now when you refresh the page the background is dark, which confirms that the variable is controlling it.

Dark Mode Broken

Of course the remaining elements of the page have not been adapted to use variables yet and have the same colors as before, so overall the page looks pretty bad, but as I said above, towards the end of the article I will add more variables to fix all the color issues. For now I will focus on the background color variable to proceed with the implementation.

Defining Color Themes with a Data Attribute

The switch between light and dark modes is going to be achieved by having two sets of values for all the CSS variables that control the colors of the page. The default values are going to be the ones for light mode, and a conditional CSS clause will override those values with the ones for dark mode when this mode is enabled.

CSS does not have if-then conditionals like most programming languages, but one way to get a similar effect is with attribute selectors, which allow you to apply a clause only when the corresponding element satisfies a given condition on one of its attributes.

For this I'm going to use a data attribute that I will call data-theme. The idea is that any element in the page that has data-theme="dark" will render using the dark mode variables, while any elements that don't have a data-theme attribute or have it set to data-theme="light" will render with the original light mode variables.

Here are the changes in the <style> element of the page to implement this:

    <style>
      :root, [data-theme="light"] {
        --page-bg-color: #e8e8ea;
      }
      [data-theme="dark"] {
        --page-bg-color: #18181a;
      }
      ...
    </style>

If you refresh the page you will not see any differences yet, the light background will still be active because no elements in the page have a data-theme attribute, so the value of the --page-bg-color variable comes from the :root section.

To manually test the switch to dark mode, add a data-theme attribute to the <html> element in the page:

<doctype !html>
<html lang="en" data-theme="dark">
  ...
</html>

Now when you refresh the page the dark background will be used. You can also try changing the data-theme attribute to "light", which should also work.

Setting the Theme In Templates

The page now has a system to switch between light and dark modes, but this has to be done manually by editing the <html> element.

If your application renders HTML templates in the server, one possible implementation would be to ask the user in a configuration form to select light or dark mode. This information would be stored by your server, possibly in the users table in your database or in the user session, so then you can use it to render the correct theme. Using Jinja or other handlerbars-style template engines, for example, you would do something like this:

<html lang="en" data-theme="{{ theme }}">

While this is a decent solution, it requires the back end to keep track of which theme each user wants to use. More importantly it requires all the users who prefer dark mode to explicitly open a configuration page and choose this mode over the default, even though they have already expressed their preference in their operating system settings. The next section describes what I consider a nicer implementation.

Setting the Theme Automatically

A better approach would be to obtain the light vs. dark mode setting from the user's operating system, and use that to give a value to the data-theme attribute, without the user having to do anything.

The browser has APIs for all sorts of things, and among them, it has one that provides access to the light vs. dark mode setting in the host operating system. This can be done with the matchMedia() function, as follows:

const theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'

This expression runs a media query to check if the user has configured a preference for dark mode, and in that case assumes that the dark theme should be used. Otherwise it goes with the light theme.

Once the theme has been determined, it can be added programmatically to the <html> node:

document.documentElement.setAttribute('data-theme', theme)

The logic to automatically set the preferred theme can be added to the example page in a <script> tag:

<doctype !html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Themes Example</title>
    <style>
      ...
    </style>
    <script>
      const theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
      document.documentElement.setAttribute('data-theme', theme)
    </script>
  </head>
  <body>
    ...
  </body>
</html>

Now when you open the page it should automatically start with the mode that matches your operating system theme.

Detecting Theme Changes

There is actually one more thing that can be done to add a very nice touch to this implementation. You may have noticed that if you switch between light and dark mode in your operating system's configuration, most open windows automatically update, but the example page from this article requires a refresh to pick up the new theme.

The browser lets you configure a callback function that fires when the theme selection is changed in the system, and this is a good place to refresh the data-theme attribute, which in turn will cause the variables to be recalculated.

Here is the final bit of JavaScript logic to accomplish this:

    <script>
      const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)')

      function updateTheme() {
        const theme = prefersDarkMode.matches ? 'dark' : 'light'
        document.documentElement.setAttribute('data-theme', theme)
      }

      updateTheme();
      prefersDarkMode.addEventListener("change", () => updateTheme());
    </script>

I am now saving the media query on the prefersDarkMode variable, since I'm going to use it not only to determine the theme, but also to set up a listener for when it changes.

The two lines that obtained and set the theme are now in a updateTheme() function. This function is called directly so that the theme is configured when the page is opened, and it is also added in a change callback, so that theme is refreshed when the user reconfigures it in their system.

Refresh the page one more time to make sure that you have the latest version, and then go into your operating system configuration to change between light and dark modes. The page should now change its theme automatically.

One more nice addition that you may want to consider is to allow the user to optionally override the automatic light or dark mode selection. This is what I have chosen to do for this blog, which you can test using the dropdown in the navigation bar. The idea is that there are three possible theme selections: light, dark or automatic. The default is automatic, which triggers the logic I showed above to obtain the theme from the system. But if the user explicitly selects light or dark modes, then the chosen theme is used directly, and I store this selection in local storage so that it can be recalled on future visits. I will leave the implementation of this as an exercise, since it should be fairly straightforward.

Complete Example

The last bit of work that remains to have a complete example is to add variables for the remaining colors that are used in the CSS styles. Below you can find the complete example, with all the colors referenced in CSS styles moved to variables:

<doctype !html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Themes Example</title>
    <style>
      :root, [data-theme="light"] {
        --page-bg-color: #e8e8ea;
        --page-fg-color: #333333;
        --h1-fg-color: #44446a;
        --h2-fg-color: #64648a;
        --code-fg-color: #a83030;
        --code-bg-color: #d8d8d8;
      }
      [data-theme="dark"] {
        --page-bg-color: #18181a;
        --page-fg-color: #cccccc;
        --h1-fg-color: #bbbbda;
        --h2-fg-color: #9999aa;
        --code-fg-color: #f86060;
        --code-bg-color: #444444;
      }
      body {
        font-family: sans-serif;
        background-color: var(--page-bg-color);
        color: var(--page-fg-color);
      }
      h1 {
        color: var(--h1-fg-color);
      }
      h2 {
        color: var(--h2-fg-color);
      }
      code {
        font-size: 130%;
        color: var(--code-fg-color);
        background-color: var(--code-bg-color);
        padding: 2px;
      }
    </style>
    <script>
      const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)')

      function updateTheme() {
        const theme = prefersDarkMode.matches ? 'dark' : 'light'
        document.documentElement.setAttribute('data-theme', theme)
      }

      updateTheme();
      prefersDarkMode.addEventListener("change", () => updateTheme());
    </script>
  </head>
  <body>
    <h1>Themes Example</h1>
    <p>This is a paragraph.</p>
    <h2>Level 2 heading</h2>
    <p>Another paragraph with a <code>code</code> word.</p>
  </body>
</html>

Here is how the dark and light modes look with the variables:

Dark Mode

Light Mode

If You Are Using Bootstrap

I thought I'd add a short note for those of you who work with the Bootstrap framework.

In version 5.3, Bootstrap introduced official support for light and dark modes. This is nice because the framework now includes light and dark color definitions for all of its components, so you will only need to create variables for your custom colors.

The Bootstrap implementation of themes is very close to the solution I have described here, and it is designed to be extended with your own custom definitions. The choice of theme is controlled with a data attribute named data-bs-theme instead of the data-theme that I have used in the above example, but outside of the name difference everything else works pretty much the same.

Bootstrap does not currently include an automatic theme switcher, so the page must include a <script> section with logic to select the correct mode based on system preferences, as I show in the example page.

Conclusion

I hope this article gives you all the techniques that you need to implement light and dark modes for your website. If you have any questions or ideas to improve my solution, write them below in the comments.

Become a Patron!

Hello, and thank you for visiting my blog! If you enjoyed this article, please consider supporting my work on this blog on Patreon!

6 comments
  • #1 Gitau Harrison said

    Simply done, and it is an addition that is welcome.

  • #2 Cosme12 said

    Hey Miguel! Nice post, really helpful. I just implemented this code but I'm getting a little flickery when reloading the website. Like it blinks in light mode and then changes back to dark. I also noticed it happens, from time to time, in your blog too.
    Could you give me any hint of where to look at to fix it? Thanks!

  • #3 Frank Yu said

    Great article, and it's better to place the js code inline head tag to avoid FOUC, this blogsite has the flickering issue now.

  • #4 Miguel Grinberg said

    @Cosme12 & @Frank: The solution I present in this article is the correct one, with the theme switching logic in the <head> element. As you have noticed, I did not do this in the blog implementation. I have corrected it now and believe it should not flash anymore, but please let me know if you continue to see flashing.

  • #5 Nickbap said

    Hey Miguel, Just wanted to say thanks for always sharing such useful information!

  • #6 Oscar Rivas said

    Thanks for always sharing your knowledge, Miguel.
    P.S. I Miss your YouTube videos.

    Cheers from New Zealand.

Leave a Comment