Dark Mode. System Preference. Pre-render.

Setting up dark mode with better UX.
4 min read·Oct 21, 2022

While implementing dark mode feature for this website, I faced some non-trivial challenges, mainly:

  • how to make it reactive to the user's device preference
  • how to prevent a flash of default theme that happens before the client-side javascript runs

But, before diving into any of that, I want to briefly talk about my setup first.


So, supporting dark mode means, now there are two different themes, say light and dark.

And every time users change their theme, the change should be saved somewhere as well, so when they come back later, the website would remember what it was, and show it to them.

I use browser's localStorage APIs to save the user preference.

Also, changing themes means changing the style of the website, which is handled by css and I use tailwindcss to write css. This is important to mention because toggling dark mode on/off is taken care of by tailwindcss.

So, enabling/disabling dark mode in tailwindcss is done by adding/removing dark class to the top html element: (see here for more info)

<!-- Dark mode not enabled -->
<html>
  <body>
    <!-- Will be white -->
    <div class="bg-white dark:bg-black">
      <!-- ... -->
    </div>
  </body>
</html>

<!-- Dark mode enabled -->
<html class="dark">
  <body>
    <!-- Will be black -->
    <div class="bg-white dark:bg-black">
      <!-- ... -->
    </div>
  </body>
</html>

In summary, two side effects must happen whenever dark mode is toggled:

  • update user's preference in localStorage
  • add/remove dark class in <html>

These side effects are triggered whenever a react state called theme changes.

So the gist of it is that the side effects handle the persistency of user preference and actual style change, whereas the react state theme triggers other UI changes that are not affected by adding/removing dark class to <html>—something like icon change and code-block theme change.


Now moving on to the first issue:

how do we adjust the theme accordingly when user's device preference has changed?

This can be achieved by another set of browser feature and API: prefers-color-scheme media query and Window.matchMedia().

We can detect the change of the user preference by adding an event listener to the return value of window.matchMedia():

const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
darkQuery.addEventListener("change", (event) => {
  /**
   * event.matches
   *
   * "A boolean value that is true if the document currently matches the media query list, or false if not."
   *
   * ref: https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryListEvent/matches
   */
  console.log(event.matches);

  if (event.matches) {
    // Set react state `theme` to 'dark'.
    // which will also trigger the side effects after react renders.
  } else {
    // Set react state `theme` to 'light'.
    // which will also trigger the side effects after react renders.
  }
});

If you want to see how it's actually implemented for this website, see here: globalState.ts


If configured correctly, it should work like this:

syncing system theme with prefer-color-scheme media query
Syncing with the OS preference

Now that we solved the syncing, let's look at the second challenge:

How do we prevent a flash of the default theme that happens when the server-generated html arrives, but before the client javascript picks up the value from localStorage?

You wouldn't have this issue if you only use client-side rendering with an emtpty html that's going to be populated later by client-side javascript. But it becomes a bit of a nuisance if you use a framework that generates pre-rendered html such as Next.js (see here)

What I meant by a flash of the default theme is something like this:

default theme flash when first load the page
A flash of the default theme

One way to prevent this is to insert a <script> tag before any other scripts in the document, and have it check the localStorage or the system preference and change the class of html accordingly.

This is possible because as soon as the browser finds this first <script> tag—which will be located inside <head> above any other scripts in the document—it will execute this script before it moves on to parse the <body> tag.

Scripts without async, defer or type="module" attributes, as well as inline scripts without the type="module" attribute, are fetched and executed immediately, before the browser continues to parse the page.

Next.js provides <Script/> component that takes a prop called strategy.

And if this strategy is set to beforeInteractive, the script is guaranteed to run first. (see: beforeInteractive)

So my _document.tsx looks something like this:

import { Html, Head, Main, NextScript } from "next/document";
import Script from "next/script";

export default function Document() {
  return (
    <Html className="w-full">
      <Head>
        <Script id="theme-script" strategy="beforeInteractive">
          {`
            (function() {
              function setTheme(theme) {
                switch (theme) {
                  case "light":
                    document.documentElement.classList.remove('dark');
                    localStorage.setItem("theme", "light");
                    break;
                  case "dark":
                    document.documentElement.classList.add('dark');
                    localStorage.setItem("theme", "dark");
                    break;
                }
              }

              const darkQuery = window.matchMedia('(prefers-color-scheme: dark)')
              
              if (localStorage.theme === 'dark' || (!('theme' in localStorage) && darkQuery.matches)) {
                setTheme('dark');
              } else {
                setTheme('light');
              }
            })();
          `}
        </Script>
      </Head>
      <body className="flex flex-col">
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

By adding or removing 'dark' class from <html>, tailwindcss will be able to correctly style the <body> according to the theme, later when browser starts to parse the body.

And yeah, so with the help of this inline script, there will be no flashing anymore!

no more theme flash by adding a script that runs before the main script
No more flash

Useful links:

Back to list