Home

The Perfect Dark Mode

Table of contents

The Problem

So what's the big deal? What really is the perfect dark mode?

If you take a look at a website which has support for dark mode like mdxjs.com, you'll notice something if you try to refresh the page once you've enabled dark mode.

The dreaded flicker of light mode. ugh.

So why does this happen?

This is a problem that is not limited to static/hybrid websites but extends to pretty much any website that uses JavaScript to "hydrate" its components. This is because when our page loads up, here's that happens:

The Solution

So what should we do? We need to find a way to be able to run some code and apply the appropriate background-color (and by extension, the theme) before the entire page has loaded.

Here's a rough list of what we need to implement:

Let's start by putting together a simple Next.js page with a pretty basic dark mode toggle:

pages/index.jstsx
import { useState } from "react";const IndexPage = () => {  const [isDarkTheme, setIsDarkTheme] = useState(undefined);  const handleToggle = (event) => {    setIsDarkTheme(event.target.checked);  };  return (    <div>      <label>        <input          type="checkbox"          checked={isDarkTheme}          onChange={handleToggle}        />{" "}        Dark      </label>      <h1>Hello there</h1>      <p>General Kenobi!</p>    </div>  );};export default IndexPage;

Storing (and retrieving) user preference

Let's begin by adding the ability to store and retrieve the preference if the user has already visited our website before. localStorage is a really simple way of accomplishing exactly this, even when a user refreshes the page or closes the browser completely and opens it again at a later time. Although there are concerns over storing sensitive and/or large data in localStorage, it is perfect for storing our user's dark mode preference.

Here's how we can save and load our theme preference using localStorage:

typescript
window.localStorage.setItem("theme", "dark"); // or "light"const userPreference = window.localStorage.getItem("theme"); // "dark"

System-wide preference

prefers-color-scheme is a CSS media feature that allows us to detect if the user has set any system-wide dark mode preferences, which we can use in case the user hasn't set a preference yet.

All we need to do is run a CSS media query, and the browser provides us with matchMedia() to do exactly this!

Here's what a media query to check if the user has set any preference looks like:

typescript
const mql = window.matchMedia("(prefers-color-scheme: dark)");

with the output (when the user has set a preference for dark mode):

json
{  "matches": true,  "media": "(prefers-color-scheme: dark)"}

Let's add these to our app

pages/index.jstsx
import { useState } from "react";const IndexPage = () => {  const [isDarkTheme, setIsDarkTheme] = useState(undefined);  const handleToggle = (event) => {    setIsDarkTheme(event.target.checked);  };  const getMediaQueryPreference = () => {    const mediaQuery = "(prefers-color-scheme: dark)";    const mql = window.matchMedia(mediaQuery);    const hasPreference = typeof mql.matches === "boolean";    if (hasPreference) {      return mql.matches ? "dark" : "light";    }  };  const storeUserSetPreference = (pref) => {    localStorage.setItem("theme", pref);  };  const getUserSetPreference = () => {    localStorage.getItem("theme");  };  useEffect(() => {    const userSetPreference = getUserSetPreference();    if (userSetPreference !== null) {      setIsDarkTheme(userSetPreference === "dark");    } else {      const mediaQueryPreference = getMediaQueryPreference();      setIsDarkTheme(mediaQueryPreference === "dark");    }  }, []);  useEffect(() => {    if (typeof isDarkTheme !== "undefined") {      if (isDarkTheme) {        storeUserSetPreference("dark");      } else {        storeUserSetPreference("light");      }    }  }, [isDarkTheme]);  return (    <div>      <label>        <input          type="checkbox"          checked={isDarkTheme}          onChange={handleToggle}        />{" "}        Dark      </label>      <h1>Hello there</h1>      <p>General Kenobi!</p>    </div>  );};export default IndexPage;

Great! We've got a toggle working, and we're also able to store and retrieve the correct state in our page

Back to Basics

The biggest challenge (surprisingly) was being able to run all these checks before anything is shown to the user. Since we're using Next.js with its Static Generation, there's no way for us to know at code/build time what the user's preference is going to be 🤷‍♂️

Unless...there was a way run some code before all of our page is loaded and rendered to the user!

Take a look at the code below:

html
<body>  <script>    alert("No UI for you!");  </script>  <h1>Page Title</h1></body>

Here's what it looks like:

When we add a <script> in our body before our <h1> content, the rendering of the actual content is blocked by the script. This means that we can run code that will be guaranteed to run before any content is shown to the user, which is exactly what we wanna do!

Next.js' Document

From the example above, we know now that we need to add a <script> in the <body> of our page before the actual content.

Next.js provides a super sweet and easy way of modifying the <html> and <body> tags in our app by adding a _document.tsx (or _document.js) file. The Document is only rendered in the server, so our script is loaded as we describe it on the client browser.

Using this, here's how we can add our script:

tsx
import Document, {  Html,  Head,  Main,  NextScript,} from "next/document";export default class MyDocument extends Document {  render() {    return (      <Html>        <Head />        <body>          <script            dangerouslySetInnerHTML={{              __html: customScript,            }}          ></script>          <Main />          <NextScript />        </body>      </Html>    );  }}const customScript = `		console.log("Our custom script runs!");`;

Dangerously set whaa?

The browser DOM provides us with innerHTML to get or set the HTML contained within an element. Usually, setting HTML from code is risky business because it is easy to inadvertently expose users to a cross-site scripting (XSS) attack. React protects us from this by default, by sanitising the contents before rendering it.

If a user tries to set their name to <script>I'm dangerous!</script>, React encodes characters like < into &lt;. This way, the script has no effect.

React also provides a way to override this behaviour using dangerouslySetInnerHTML, reminding us that it is dangerous. Well, in our use case, we actually do want to inject and run a script.

We're almost there!

We now know how to make sure that our script is loaded before the rest of the page (and with the help of Next.js' Document, before any page), but we still need a couple more pieces of this puzzle:

IIFEs

The next piece of our puzzle is figuring out how to run our custom script as soon as possible. As a reminder, we're doing this to figure out the correct state of dark mode (activated/deactivated, or more simply, true/false) to avoid any ungodly "flashes" of toggling when the user loads up our webpage.

Enter Immediately Invoked Function Expressions! (or IIFEs for short)

An IIFE is simply a JavaScript function that is executed as soon as it is defined. Aside from having the benefit of being run Immediately upon definition, IIFEs are also great when one wants to avoid polluting the global namespace — something that we can definitely use since we have no use for our logic once it has run and set the apt mode.

Here's what an IIFE looks like:

typescript
(function () {  var name = "Sreetam Das";  console.log(name);  // "Sreetam Das"})();// Variable name is not accessible from the outside scopeconsole.log(name);// throws "Uncaught ReferenceError: name is not defined"

Let's add this to our _document.js

tsx
import Document, {  Html,  Head,  Main,  NextScript,} from "next/document";function setInitialColorMode() {  function getInitialColorMode() {    const preference = window.localStorage.getItem("theme");    const hasExplicitPreference = typeof preference === "string";    /**     * If the user has explicitly chosen light or dark,     * use it. Otherwise, this value will be null.     */    if (hasExplicitPreference) {      return preference;    }    // If there is no saved preference, use a media query    const mediaQuery = "(prefers-color-scheme: dark)";    const mql = window.matchMedia(mediaQuery);    const hasImplicitPreference = typeof mql.matches === "boolean";    if (hasImplicitPreference) {      return mql.matches ? "dark" : "light";    }    // default to 'light'.    return "light";  }  const colorMode = getInitialColorMode();}// our function needs to be a stringconst blockingSetInitialColorMode = `(function() {    ${setInitialColorMode.toString()}    setInitialColorMode();})()`;export default class MyDocument extends Document {  render() {    return (      <Html>        <Head />        <body>          <script            dangerouslySetInnerHTML={{              __html: blockingSetInitialColorMode,            }}          ></script>          <Main />          <NextScript />        </body>      </Html>    );  }}

We're now able to correctly retrieve the appropriate state of our dark mode before the page loads completely! Our final hurdle is now being able to pass this on to our page's component so that we can actually apply the preferred dark mode state.

The challenge here is that we need to be able to transfer this piece of information from a pure JS script which is being run before the page and its React components have been loaded completely, and "hydrate" them.

CSS Variables

The last step is to update our page with the user's preferred theme.

There are multiple ways to go about this:

While all of the options seem like possible solutions, they each require a lot more boilerplate to be added

or we could CSS variables!

CSS Custom Properties (also referred to as CSS Variables) allow us to reuse specific values throughout a document. These can be set using custom property notation and accessed using the var() function like so:

css
:root {  --color-primary-accent: #5b34da;}

The best part about CSS variables is that they are reactive, they remain live throughout the lifetime of the page, and updating them updates the HTML that references them instantly. And they can be updated using JavaScript!

tsx
// settingconst root = document.documentElement;root.style.setProperty("--initial-color-mode", "dark");// gettingconst root = window.document.documentElement;const initial = root.style.getPropertyValue("--initial-color-mode");// "dark"

CSS variables really shine when you want to have to reuse certain values in your CSS; my website uses a few that you can check out

There's more!

We can use HTML attributes and since CSS also has access to these attributes, we can assign different values to CSS variables depending on the data-theme attribute that we set, like so:

css
:root {  --color-primary-accent: #5b34da;  --color-primary: #000;  --color-background: #fff;  --color-secondary-accent: #358ef1;}[data-theme="dark"] {  --color-primary-accent: #9d86e9;  --color-secondary-accent: #61dafb;  --color-primary: #fff;  --color-background: #000;}[data-theme="batman"] {  --color-primary-accent: #ffff00;}

and we can set and remove the attribute pretty easily too:

tsx
if (userPreference === "dark")  document.documentElement.setAttribute("data-theme", "dark");// and to remove, setting the "light" mode:document.documentElement.removeAttribute("data-theme");

Finally, we're now able to pass on the computed dark mode state from our blocking script to our React component.

Recap

Before we put together everything we have so far, let's recap:

Here's what the final result looks like:

pages/_document.jstsx
import Document, {  Html,  Head,  Main,  NextScript,} from "next/document";function setInitialColorMode() {  function getInitialColorMode() {    const preference = window.localStorage.getItem("theme");    const hasExplicitPreference = typeof preference === "string";    /**     * If the user has explicitly chosen light or dark,     * use it. Otherwise, this value will be null.     */    if (hasExplicitPreference) {      return preference;    }    // If there is no saved preference, use a media query    const mediaQuery = "(prefers-color-scheme: dark)";    const mql = window.matchMedia(mediaQuery);    const hasImplicitPreference = typeof mql.matches === "boolean";    if (hasImplicitPreference) {      return mql.matches ? "dark" : "light";    }    // default to 'light'.    return "light";  }  const colorMode = getInitialColorMode();  const root = document.documentElement;  root.style.setProperty("--initial-color-mode", colorMode);  // add HTML attribute if dark mode  if (colorMode === "dark")    document.documentElement.setAttribute("data-theme", "dark");}// our function needs to be a stringconst blockingSetInitialColorMode = `(function() {		${setInitialColorMode.toString()}		setInitialColorMode();})()`;export default class MyDocument extends Document {  render() {    return (      <Html>        <Head />        <body>          <script            dangerouslySetInnerHTML={{              __html: blockingSetInitialColorMode,            }}          ></script>          <Main />          <NextScript />        </body>      </Html>    );  }}

Note how we use style.setProperty() as well as documentElement.setAttribute() to pass our data

Let's add our CSS, adding separate values for our CSS variables when dark mode is applied

styles.csscss
:root {  --color-primary-accent: #5b34da;  --color-primary: #000;  --color-background: #fff;}[data-theme="dark"] {  --color-primary-accent: #9d86e9;  --color-primary: #fff;  --color-background: #000;}body {  background-color: var(--color-background);  color: var(--color-primary);}

Great! Now we need to import these styles into our application.

Since we want these styles to be available throughout our website, we'll need to use the App component that Next.js provides us. This is similar to the Document that we saw earlier, in that it's a special component which can be used to control each page in Next.js app as it's used to initialise our pages.

This makes it the correct place for adding our global CSS as well!

pages/_app.jstsx
import "../styles.css";export default function MyApp({ Component, pageProps }) {  return <Component {...pageProps} />;}

and finally, our React component page:

pages/index.jstsx
import { useEffect, useState } from "react";const IndexPage = () => {  const [darkTheme, setDarkTheme] = useState(undefined);  const handleToggle = (event) => {    setDarkTheme(event.target.checked);  };  const storeUserSetPreference = (pref) => {    localStorage.setItem("theme", pref);  };  const root = document.documentElement;  useEffect(() => {    const initialColorValue = root.style.getPropertyValue(      "--initial-color-mode",    );    setDarkTheme(initialColorValue === "dark");  }, []);  useEffect(() => {    if (darkTheme !== undefined) {      if (darkTheme) {        root.setAttribute("data-theme", "dark");        storeUserSetPreference("dark");      } else {        root.removeAttribute("data-theme");        storeUserSetPreference("light");      }    }  }, [darkTheme]);  return (    <div>      {darkTheme !== undefined && (        <label>          <input            type="checkbox"            checked={darkTheme}            onChange={handleToggle}          />{" "}          Dark        </label>      )}      <h1>Hello there</h1>      <p style={{ color: "var(--color-primary-accent)" }}>        General Kenobi!      </p>    </div>  );};export default IndexPage;

Of course, there are packages which can handle all of this for you including "the flash" which differ only slightly in their implementation (Donavon here uses the .class method). Also, here's a great article about the Principles of Dark UI Design that I would recommend you check out — it is a great read about the multiple challenges that one needs to take care of while implementing the perfect dark mode.

At the end of the day there are more and more people adding dark mode to their websites, and hopefully my journey here helps you implement the perfect one too. 😄

👀

No views yet. Wait what, HOW? 🤔