JericaWLancaster

The Quest for the Perfect Dark ModeA scintillating exploration of color themes in React

Filed under
React
on
in
April 22nd, 2020.
Apr 2020.
Introduction

Maybe the hardest / most complicated part of building my statically-generated blog was adding Dark Mode.

Not the live-embedded code snippets, not the unified GraphQL layer that manages and aggregates all content and data, not the custom analytics system, not the myriad bits of whimsy. Freaking Dark Mode.

It's a good reminder that when it comes to software development, a simple feature can have a complex implementation.

An XKCD comic. A project manager asks for a feature that checks whether a photo was in a natural park — an easy request — and then if it can check if the photo contains a bird — an astronomically difficult challenge.An XKCD comic. A project manager asks for a feature that checks whether a photo was in a natural park — an easy request — and then if it can check if the photo contains a bird — an astronomically difficult challenge.

The reason that this problem is so dastardly has to do with how frameworks like Next.js work; the HTML is generated ahead of time. If you're not careful, you'll wind up with that telltale flicker, where the user sees the wrong colors for a brief moment.

Today we'll learn how to build the for full-stack React applications. 😄

This post builds on the knowledge shared in two previous posts. You may wish to review these first if you're not yet familiar with React hydration or CSS variables:

Link to this headingOur requirements

Here's our set of criteria for this feature:

  • The user should be able to click a toggle to switch between Light and Dark mode.
  • The user's preference should be saved, so that future visits use the correct color theme.
  • It should default to the user's "preferred" color scheme, according to their operating system settings. If not set, it should default to Light.
  • The site should not flicker on first load, even if the user has selected a non-default color theme.
  • The site should never show the wrong toggle state.

As we'll see, those last two are where a lot of the dragons rest 🐉.

Link to this headingCharting it out

Given those requirements, what should the initial color theme be? Here's a flow-chart:

A flow chart showing how the requirements above work out: First we look at the localStorage value. If it's not set, we look at prefers-color-scheme. If that's not set, we default to “light”.

Link to this headingA first pass

Let's write a little function that helps us update our color theme, based on the conditions we wrote about earlier.

function getInitialColorMode() {
  const persistedColorPreference =
    window.localStorage.getItem('color-mode');
  const hasPersistedPreference =
    typeof persistedColorPreference === 'string';

  // If the user has explicitly chosen light or dark,
  // let's use it. Otherwise, this value will be null.
  if (hasPersistedPreference) {
    return persistedColorPreference;
  }

  // If they haven't been explicit, let's check the media query
  const mql = window.matchMedia('(prefers-color-scheme: dark)');
  const hasMediaQueryPreference = typeof mql.matches === 'boolean';

  if (hasMediaQueryPreference) {
    return mql.matches ? 'dark' : 'light';
  }

  // If they are using a browser/OS that doesn't support
  // color themes, let's default to 'light'.
  return 'light';
}

We'll need some state! It's up to you how to manage state, but in this example, we'll use React context:

function getInitialColorMode() {
  /* Same as above. Omitted for brevity */
}

export const ThemeContext = React.createContext();

export const ThemeProvider = ({ children }) => {
  const [colorMode, rawSetColorMode] =
    React.useState(getInitialColorMode);

  const setColorMode = (value) => {
    rawSetColorMode(value);

    // Persist it on update
    window.localStorage.setItem('color-mode', value);
  };

  return (
    <ThemeContext.Provider value={{ colorMode, setColorMode }}>
      {children}
    </ThemeContext.Provider>
  );
};

We pass our getInitialColorMode function to useState, to come up with our initial value. Then, we create our own helper function, setColorMode, which updates the React state but also persists the value in localStorage, for the next load. Finally, we pass everything down to a context provider.

Link to this headingOur first hurdle

If you try building this code as-is, you'll get an error.

The problem is that our getInitialColorMode function runs immediately on-mount, to figure out what the initial value should be. But in Gatsby, that first render doesn't happen on the user's device, a topic explored in depth in another post.

This is the crux of our problem. When React first renders, we have no way of knowing what the user's color preferences are, since that render happens in the cloud, potentially hours or days before the user visits.

We can assume that the user wants a light theme, and then swap it out after React has rehydrated on the client… but if we do that, we'll wind up with the dreaded flicker:

Ideally, the HTML we send to the user should already have the right colors, even on the very first frame it's painted. But we don't know what colors the user wants until it's on their device! Is a solution even possible?

Link to this headingA workable solution

Here's our solution, at a high level:

  • Use CSS variables for all of our styling
  • When we're generating the HTML at compile-time, inject a <script> tag before all of our content (the page itself)
  • In that script tag, work out what the user's color preferences are
  • Update the CSS variables using JavaScript

We're taking advantage of a few tricks in order to achieve our goal. Here's what the initial HTML looks like (before our JS bundle has been executed):

<!DOCTYPE html>
<html>
  <head>
    <title>My Awesome Website</title>
  </head>

  <body>
    <script>
      /*
        - Check localStorage
        - Check the media query
        - Update our CSS variables depending
          on those values.
      */
    </script>

    <div>
      <h1>My Awesome Website<h1>
      <p>Content here.</p>
    </div>
  </body>
</html>

Link to this headingBlocking HTML

The injected <script> tag goes before our main body content. This is important, since scripts are blocking; nothing will be painted to the screen until that JS code has been evaluated.

For example: check out what the user sees when the following HTML is run:

<body>
  <script>
    alert('No UI for you!');
  </script>

  <h1>Page Title</h1>
</body>

Notice that the <h1> isn't shown until the <script> has finished running. 😮

Link to this headingReactive CSS variables

CSS variables are really cool. Their best trick is that they're . When a variable's value changes, the HTML updates instantly.

We can use CSS variables in all of our components. For example, using styled-components:

const Button = styled.button`
  background: var(--color-primary);
`;

This will generate a button in HTML that points at our CSS variable. When we change that CSS variable with JavaScript, our button reacts to that change, and becomes the right color, without us needing to target and change the button itself.

This is the core of our strategy: rely on CSS variables for styling, and then pre-empt the HTML by tweaking the CSS variable values.

Link to this headingUpdating HTML in Gatsby

When Gatsby builds, it produces an HTML file for each page on our site. Our goal is to inject a <script> tag above that content, so that the browser parses it first.

One of the really cool things about Gatsby is that it exposes escape hatches at every step in its build process. We can hook into it, and add a bit of custom behaviour!

If you don't already have it, create a gatsby-ssr.js file in your project's root directory. gatsby-ssr.js is a file which will run when Gatsby is compiling your site (at build time).

We'll add the following code:

const MagicScriptTag = () => {
  const codeToRunOnClient = `
(function() {
  alert("Hi!");
})()
  `;

  // eslint-disable-next-line react/no-danger
  return <script dangerouslySetInnerHTML={{ __html: codeToRunOnClient }} />;
};

export const onRenderBody = ({ setPreBodyComponents }) => {
  setPreBodyComponents(<MagicScriptTag />);
};

There's a lot going on here, so let's break it down:

  • onRenderBody is a lifecycle method(opens in new tab) that Gatsby exposes. It will run this function when generating our HTML during the build process.
  • setPreBodyComponents is a function which will inject a React element above everything else it builds (our actual site), within the <body> tags.
  • MagicScriptTag is a React component, and it renders a <script> tag. We pass it a stringified snippet and use dangerouslySetInnerHTML to embed that script in the returned element.
  • We use an IIFE(opens in new tab) (rocking it oldschool!) to avoid polluting the global namespace.

Link to this headingCrossing the chasm

You may wonder why we're putting our code in a string and rendering it. Can't we just call that function normally?

I like to think of this as a "chasm" in space and time. There's the moment where we build our code, on our computer or in the cloud. And then there's the moment the client runs our code, on their device, at a very different place and time.

We want to write some code that will be injected at compile-time, but only executed at run-time. We need to pass that function as if it was a piece of data, to be run on the user's device.

We have to do it this way because we don't have the right information yet; we don't know what's in the user's localStorage, or whether their operating system is running in dark mode. We won't know that until we're running code on the user's device.

The opposite is also true! When this code eventually runs, it won't have access to any of our bundled JS code; it will run before the bundle has even been downloaded! That means it won't automatically know what our design tokens are.

Link to this headingGenerating the script

By using some string interpolation, we can "generate" the function we'll need:

const MagicScriptTag = () => {
  let codeToRunOnClient = `
(function() {
  function getInitialColorMode() {
    /* Same code as earlier */
  }

  const colorMode = getInitialColorMode();

  const root = document.documentElement;

  root.style.setProperty(
    '--color-text',
    colorMode === 'light'
      ? '${COLORS.light.text}'
      : '${COLORS.dark.text}'
  );
  root.style.setProperty(
    '--color-background',
    colorMode === 'light'
      ? '${COLORS.light.background}'
      : '${COLORS.dark.background}'
  );
  root.style.setProperty(
    '--color-primary',
    colorMode === 'light'
      ? '${COLORS.light.primary}'
      : '${COLORS.dark.primary}'
  );

  root.style.setProperty('--initial-color-mode', colorMode);
})()`;

  // eslint-disable-next-line react/no-danger
  return <script dangerouslySetInnerHTML={{ __html: codeToRunOnClient }} />;
};

We move our getInitialColorMode function into this string (we can't simply import it and call it! We need to copy/paste it). Once we know what the initial color should be, we can start setting our CSS variables. We use string interpolation, so that the actual script being injected will look like:

root.style.setProperty(
  '--color-text',
  colorMode === 'light'
    ? 'black' // resolves from ${COLORS.light.text}
    : 'white' // resolves from ${COLORS.dark.text}
);

To keep things as straightforward as possible, we're calling root.style.setProperty manually for every color in our site. As you might imagine, this gets a little tedious when you have dozens of colors! We'll talk about potential optimizations in the appendix.

We also set one last property, --initial-color-mode. This is a potato we're passing from this runtime script to our React app; it will read this value in order to figure out what the initial React state should be.

Link to this headingState management

If we didn't want to give users the option to toggle between light and dark mode, our work would be done! The initial state is perfect.

No dark mode is complete without a toggle, though. We want to let users pick whether our site should be light or dark! We can make an educated guess based on their operating system's settings, but just because a user likes a dark OS doesn't mean they want our website in dark colors, and vice versa.

Here's how we capture that state in React:

export const ThemeContext = React.createContext();

export const ThemeProvider = ({ children }) => {
  const [colorMode, rawSetColorMode] = React.useState(undefined);

  React.useEffect(() => {
    const root = window.document.documentElement;

    const initialColorValue = root.style.getPropertyValue(
      '--initial-color-mode'
    );

    rawSetColorMode(initialColorValue);
  }, []);

  const setColorMode = (value) => {
    /* TODO */
  };

  return (
    <ThemeContext.Provider value={{ colorMode, setColorMode }}>
      {children}
    </ThemeContext.Provider>
  );
};

To highlight relevant bits:

  • We're initializing the state with undefined. This is because for the first render (at compile-time), we don't have any access to the window object.
  • Immediately after the React app rehydrates, we grab the root element, and check what its --initial-color-mode is set to.
  • We set this into our state, so that now our React state has inherited the CSS variable's value we set in onRenderBody.

Link to this headingOrder of operations

It can be hard to visualize this sequence of events, so I built a little interactive gismo. Tap or hover over each step to view a bit more context.

Compile-time

These steps run on a server somewhere, far ahead of time.

Gatsby creates an HTML file for each page by server-side-rendering your React components
Gatsby calls the `render` method for each of your pages, generating the initial HTML

Run-time

These steps run on the user's device.

Immediately when the page loads, our <script> is executed to set some CSS variables on the root node.
The initial pre-compiled HTML is parsed and displayed to the user
The React app mounts, adopting the existing HTML.
After mount, we grab the user's color preferences off of the root node's custom property, and set it into state.

Link to this headingAdding a toggle

We've set up our state management code; let's use it to build a toggle!

When the toggle is triggered, here's what we'll need to do:

  1. Update the React state that tracks the current color mode.
  2. Update localStorage, so that we remember their preferences for next time.
  3. Update all the CSS variables, so that they point to different colors.

Here's what that handler looks like:

function setColorMode(newValue) {
  const root = window.document.documentElement;

  // 1. Update React color-mode state
  rawSetColorMode(newValue);

  // 2. Update localStorage
  localStorage.setItem('color-mode', newValue);

  // 3. Update each color
  root.style.setProperty(
    '--color-text',
    newValue === 'light' ? COLORS.light.text : COLORS.dark.text
  );
  root.style.setProperty(
    '--color-background',
    newValue === 'light' ? COLORS.light.background : COLORS.dark.background
  );
  root.style.setProperty(
    '--color-primary',
    newValue === 'light' ? COLORS.light.primary : COLORS.dark.primary
  );
}

Again, to keep things as simple as possible, we aren't doing any fancy iteration to generate the setProperty calls.

For my blog, I built a fancy animated toggle, which morphs between a sun and a moon:

Building this toggle is beyond the scope of this tutorial, though I will be writing about it soon (subscribe to my newsletter if you'd like to be notified when it's released!). We'll look at how to use React Spring and SVG masks to build awesome UI flourishes like this.

For now, in order to focus on the logic, we'll stick with a rather more understated toggle:

Here's how it works:

import { ThemeContext } from './ThemeContext';

const DarkToggle = () => {
  const { colorMode, setColorMode } = React.useContext(ThemeContext);

  return (
    <label>
      <input
        type="checkbox"
        checked={colorMode === 'dark'}
        onChange={(ev) => {
          setColorMode(ev.target.checked ? 'dark' : 'light');
        }}
      />{' '}
      Dark
    </label>
  );
};

We have a checkbox, and we set its value to colorMode, pulled from context. When the user toggles the checkbox, we call our setColorMode function with the alternative color mode.

There's a problem though! When we build our site for production, our checkbox doesn't always initialize in the right state:

Remember, the initial render happens in the cloud at compile-time, so colorMode will initially be undefined. Every user gets the same HTML, and that HTML will always come with an unchecked checkbox.

Our best bet is to defer rendering of the toggle until after the React app knows what the colorMode should be:

const DarkToggle = () => {
  const { colorMode, setColorMode } = React.useContext(ThemeContext);

  if (!colorMode) {
    return null;
  }

  return <label>{/* Unchanged */}</label>;
};

By not rendering anything on the first compile-time pass, we leave a blank spot that can be filled in on the client, when that data is known to React:

Link to this headingSuccess! 🌈

We've reached a solution that ticks all of our boxes: our users see the correct color scheme from the very first frame, and they're never shown a toggle in the wrong state.

I've published a repo with this solution on Github(opens in new tab). It takes a few liberties when it comes to optimizations and cleanups, explored in the appendix below, but the core ideas are all the same. Feel free to dig into it!

Implementing a no-compromises Dark Mode is non-trivial, but I think it's worth the trouble. Little details matter, and it's especially important to avoid UI glitches in the first few seconds of a user's visit!

Link to this headingAppendix: Tweaks

There are a few more small tweaks and optimizations I've made to the solution. Let's talk about them!

Link to this headingIteration

In our example, we wind up repeating the setProperty code in two places:

  • Inside gatsby-ssr.js, when creating the initial variables.
  • Inside our ThemeProvider component, when toggling modes.

The annoying thing with this duplication is that you need to remember to update both spots when you add or change a design token. We can fix that by generating them dynamically:

Object.entries(COLORS).forEach(([name, colorByTheme]) => {
  const cssVarName = `--color-${name}`;

  root.style.setProperty(cssVarName, colorByTheme[newValue]);
});

This is a nice little win because you can tweak colors and sizes without having to think about this process at all.

The exact code you'll need depends on the structure of your design tokens.

Link to this headingNo JavaScript

One of the neat things about Gatsby is that many Gatsby sites work with JS disabled. Our current solution doesn't account for that; if you visit this site without JS enabled, everything is rendered properly, but there are no colors 😱

We can fix this by injecting a <style> tag into the <head> of our document, during the build in gatsby-ssr.js. In the same way that we inject a script tag to tweak the colors at runtime, we can inject a style tag to set defaults that will be used in the event that JS is disabled.

Here's a quick and dirty example:

const FallbackStyles = () => {
  return (
    <style>
      {`
        html {
          --color-text: ${COLORS.text};
          --color-background: ${COLORS.background};
          --color-primary: ${COLORS.primary};
        }
      `}
    </style>
  );
};

export const onRenderBody = ({ setPreBodyComponents, setHeadComponents }) => {
  setHeadComponents(<FallbackStyles />);
  setPreBodyComponents(<MagicScriptTag />);
};

You could generate each key/value pair dynamically to avoid the duplication of styles.

This fix was added to the example repo(opens in new tab), so check out a "real-world" example there!

Link to this headingMinification

For a long time now, JS code has gone through a process of “minification” or “uglification”; we take perfectly legible code and garble it so that it takes up the least amount of space possible.

This happens automatically when using build systems like Gatsby or Create React App, but it isn't happening for that little script we inject! That work happens outside the module build system; webpack doesn't know about it.

I tried using a dependency called Terser(opens in new tab). It takes a string of source code and performs a number of operations to make it smaller. You use it like this:

const outputCode = Terser.minify(inputCode).code;

I tried this on my blog, and the difference was pretty negligible (~200 bytes). Your mileage may vary, depending on how much work you're doing in that injected script!

Link to this headingScript generation

In gatsby-ssr, we inject a script tag, and we do so by providing a string that will be executed later as a function.

Writing a function within a string is no fun, though; we don't get any sort of static checking, no Prettier support, no squiggly red underlines when we typo something.

We can get around this by writing a function the traditional way, but then stringifying it:

function doStuff() {
  /* stuff */
}

String(doStuff);

This has some gotchas though!

  • We want to use this function as an IIFE, to prevent leaking into the global state
  • We need to somehow pass it our design tokens! Unlike "regular" functions, we won't be able to rely on parent scopes in this case, since it will be executed in an entirely different context.

I solved both of these woes by making some tweaks after the stringification:

function setColorsByTheme() {
  const colors = '🌈';

  // Do stuff with `colors`, as if it was an object
  // that held everything!
}

const MagicScriptTag = () => {
  // Replace that rainbow string with our COLORS object.
  // We need to stringify it as JSON so that it isn't
  // inserted as [object Object].
  const functionString = String(setColorsByTheme).replace(
    "'🌈'",
    JSON.stringify(COLORS)
  );

  // Wrap it in an IIFE
  let codeToRunOnClient = `(${functionString})()`;

  // Inject it
  return (
    <script
      dangerouslySetInnerHTML={{ __html: codeToRunOnClient }}
    />
  );
};

This last tweak feels a bit like a wash to me; we've gained IDE support, but we've made our code a fair bit more complicated / unintuitive. I opted to keep it because I like the 🌈 emoji, but you might prefer to make different tradeoffs 😄

There are lots of potential optimizations and tweaks, but at the end of the day, the most important thing is the user experience, and we've achieved a solid one with this solution ✨

Last updated on

April 22nd, 2020

# of hits