Wednesday, April 28, 2021

Dark mode for Next.js statically-generated apps using styled-components (and good UX)

In this post I will explain one way to implement dark mode for a Next.js Statically-generated application (SSG) using styled-components.

Good dark mode should:

  • Adapt to user's system color mode.
  • Give the ability to switch between color modes, and remember user's preference
  • Avoid flash of wrong colors (The situation where the page displays light mode on load, then immediately switches to dark mode, or vice versa).

This post is using Next.js 10.0 and styled-components 5.2


we will start by adding some CSS custom properties (aka CSS variables) to the document body so that we can use it from anywhere in the app.

To do that, we need to override the default app component that Next.js uses to initialize the application.

Following the instructions here, create a ./pages/_app.js file like this one.

import { createGlobalStyle } from "styled-components";
import { COLORS } from "../constants";

const GlobalStyle = createGlobalStyle`
  body {
    --bg-color: ${COLORS.light.background};
    --text-color: ${COLORS.light.text};
    color-scheme: light;

    background-color: var(--bg-color);
    color: var(--text-color);
  }

  body.dark {
    --bg-color: ${COLORS.dark.background};
    --text-color: ${COLORS.dark.text};
    color-scheme: dark;
  }
`;

function MyApp({ Component, pageProps }) {
  return (
    <>
      <GlobalStyle />
      <Component {...pageProps} />
    </>
  );
}

export default MyApp;

We added a global style with some CSS variables attached to the body, they have light mode colors by default and we override them with the .dark class.

Of course , you can add any variables you would like to use in your app and reference them in other components.

color-scheme is just a hint to the browser to use the corresponding color mode for user-agent controls, but you don't have to use it.

Some people prefer adding custom properties to the :root element. In practice, that doesn't really matter that much. I found it easier in this case, because when using JavaScript (as you'll see in a moment), we get to set the body class name instead of setting each individual variable.
You can still add them to :root and override them in the styles of the body element or other elements through the app if you prefer. this is a good article about it.

Now we can use these variables in any styled component (or any CSS, for that matter) in the app. Imagine we have a paragraph that changes color when color mode changes, it would look something like this.

import styled from "styled-components";

export default styled.p`
    color: var(--text-color);
`;

This is probably a bad example because color is an inherited property, but you get the idea :D

Great! Now we have some components in our app that are ready to adapt to color modes.

Adapting to system color mode

Now let's make it respect the user's system color mode. To do so, we need to use the prefers-color-scheme media query.

We could do something like this in our styles

@media (prefers-color-scheme: dark) {
  --bg-color: ${COLORS.dark.background};
  --text-color: ${COLORS.dark.text};
}

But the problem is that we want to store user's preference, so if they prefer light mode while their device is on dark mode, they will see a flash of dark mode in this case when the page opens (and vice versa), so this media query here won't help us with our end goal.

We will move that check to JavaScript instead.

Let's also create a React context while we're at it, so that we can share and update the color mode from anywhere in the app.

import {
  createContext,
  useContext,
  useEffect,
  useState,
} from "react";

const ColorModeContext = createContext();

// Called when the app first mounts
// to decide which state the context should have initially
function getInitialColorMode() {
  if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
    return "dark";
  }

  return "light";
}

function activateColorMode(colorMode) {
  if (colorMode === "dark") {
    document.body.classList.add("dark");
  } else {
    document.body.classList.remove("dark");
  }
}

function ColorModeProvider({ children }) {
  const [colorMode, setColorMode] = useState(null);

 // `window` doesn't exist while rendering on the server
 // So we defer the decision on the initial color mode to when the component actually runs in the browser.
 // This also prevents mismatches between server-side and client-side renders
  useEffect(() => {
    setColorMode(getInitialColorMode());
  }, []);

  useEffect(() => {
    if (!colorMode) {
      return;
    }
    
    activateColorMode(colorMode);
  }, [colorMode]);

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

function useColorMode() {
  const context = useContext(ColorModeContext);

  if (!context) {
    throw new Error("Must be inside colorModeContext provider");
  }

  const { colorMode, setColorMode } = context;

  return { colorMode, setColorMode };
}

export {
  ColorModeProvider,
  useColorMode,
  getInitialColorMode,
  activateColorMode,
};

Please note that React will complain if the rendered content on the server didn't match the content on the client when rehydrated. And this is the reason why we set the color mode in a useEffect, so it'll only run client-side, deferring that decision until the second render, making React pass the first render successfully.

You might want to optimize ColorModeContext.Provider's value prop, depending on your application. Check the official React docs for more on that.

Now let's wrap our app component into this context. So in the custom pages/_app.js

function MyApp({ Component, pageProps }) {
  return (
    <>
      <GlobalStyle />
      <ColorModeProvider>
        <Component {...pageProps} />
      </ColorModeProvider>
    </>
  );
}


Switching between color modes

Now we can have a button somewhere to toggle between light and dark mode. Something like this.

import { useColorMode } from "../colorModeContext";
import { COLOR_MODES } from "../constants";

function ColorModeButton() {
  const { colorMode, setColorMode } = useColorMode();
  const isDarkMode = colorMode === COLOR_MODES.dark;
  
  <button
     onClick={() =>
       setColorMode(isDarkMode ? COLOR_MODES.light : COLOR_MODES.dark)
     }
  >
     {isDarkMode ? (
       "Turn on the lights"
    ) : (
      "Turn off the lights"
    )}
  </button>
}

export default ColorModeButton;

Now when the button is clicked, the color mode is switched. Cool stuff!


Saving user's preference

The missing piece is saving the user's choice so on their next visit they will see the color mode they've last chosen. Local storage to the rescue..

Let's modify our getInitialColorMode function in the color mode context we created earlier to also check local storage for a key we will call prefers-dark, and if found, it takes priority over system color mode.

function getInitialColorMode() {
  if (window.localStorage.getItem("prefers-dark")) {
    return window.localStorage.getItem("prefers-dark") === "true"
      ? "dark"
      : "light";
  }

  if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
    return "dark";
  }

  return "light";
}

Now to set it, we will modify the useColorMode function in the same file.

function useColorMode() {
  const context = useContext(ColorModeContext);

  if (!context) {
    throw new Error("Must be inside colorModeContext provider");
  }

  const { colorMode, setColorMode } = context;

  // A new function to save user's preference to local storage
  const setColorModePersisted = useCallback((newColorMode) => {
    setColorMode(newColorMode);
    window.localStorage.setItem(
      "prefers-dark",
      newColorMode === "dark"
    );
  }, []);

  return { colorMode, setColorMode: setColorModePersisted };
}

Let's recap what we have until now.

  • body has some CSS variables for light mode and dark mode.
  • color mode is stored in a React context and shared throughout the app.
  • A component can change the color mode by setting it in the context, an effect fires whenever color mode changes and adds or removes the dark class from the body, overriding the CSS variables.
  • Initial color mode in context is set on app mount with a useEffect, by default, it's set to the system color mode, but if the user has changed the color with the toggle button, we will remember it and use it in the next time they open the page.

So far so good, the only problem now is that our pages are pre-rendered on the server, and the server doesn't have a way to know on which mode the page should initially render.

The only way to know is to look into local storage and then otherwise test the media query at runtime, but because the page is pre-rendered and defaults to a certain (light in our case) mode, the browser renders the html it received from the server until react starts rehydrating and useEffects are run to set the correct color mode.

This will cause the flash of light mode that we talked about in the beginning.


Avoiding flash of wrong colors

We need to do these checks (local storage and media query) through JavaScript, but we want it to run immediately before any content is rendered, so could we set the CSS variables early, and therefore render in the correct mode.

This can be solved by injecting a script tag that runs before anything else to set these variables.

The way it's done in Next.js is by creating a custom document. Create a ./pages/_document.js file like this one.

import Document, { Html, Head, Main, NextScript } from "next/document";
import {
  getInitialColorMode,
  activateColorMode,
} from "../contexts/colorModeContext";

export default class MyDocument extends Document {
  static async getInitialProps(ctx) {
    ...// unrelated styled-components initialization code
  }

  render() {
    return (
      <Html>
        <Head />
        <body>
          <script
            dangerouslySetInnerHTML={{
              __html: `(() => {
                  ${getInitialColorMode.toString()}
                  ${activateColorMode.toString()}
                  activateColorMode(getInitialColorMode())
                  })()`,
            }}
          ></script>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

Here a script tag is injected right after the body's opening tag, so we are sure that the browser will stop and run it before it sees any other html in the body.

React doesn't let you render arbitrary script tags for security reasons, so we have to use the dangerouslySetInnerHTML prop, but don't worry, it's safe in that case as we're using our own code, not user input, for example.

We make use of JavaScript's toString function of the Function prototype, which serializes a function definition to a string. This way we can reuse the code we've written in the other file without having to copy/paste it.

I find that this works well and looks nice for a simple script like this. You might prefer to copy/paste the code inside the __html string and it's also fine, but the downside is that you'll lose reusability and code editor help as you'll have to use only strings.

Keep in mind that this code runs in isolation, outside any of our React/Webpack code, so we can't import/export, use variables from the outside scope, etc.

Finally we're wrapping everything inside an IIFE to keep the global scope clean.

And we're done! 🎉 The browser will render the page in the correct color mode on the first paint, then react kicks in and sets the initial color mode for the context using the same function. The user can change the color mode and it will persist and be used the next time they open the app.

One last thing, with our current setup, in our toggle component you would see a flash of wrong state. That's because it depends on the state from the context, which is not known until react runs in the browser and useEffect sets the correct mode.

So until that happens, our button might show "Turn off the lights" while it's dark mode.

We can get around this by only rendering the button if we know the color mode. It will defer rendering the button until the color mode is set but it's a better user experience (the button won't be interactive until then anyway).

import { useColorMode } from "../contexts/colorModeContext";
import { COLOR_MODES } from "../constants";

function ColorModeButton() {
  const { colorMode, setColorMode } = useColorMode();
  const isDarkMode = colorMode === COLOR_MODES.dark;
  
  if(!colorMode) {
    return null;
  }
  
  return (
    <button
      onClick={() =>
         setColorMode(isDarkMode ? COLOR_MODES.light : COLOR_MODES.dark)
         }
   >
      {isDarkMode ? (
         "Turn on the lights"
      ) : (
        "Turn off the lights"
      )}
      </button>
  )
}

export default ColorModeButton;

And that's all I got.


Code samples were mostly copied from a simple open source project I created for my short stories, arc. You can see the code here, and you can also see it in action here (and maybe enjoy a story or two along the way as well!).

I hope you found it useful. And as always, leave a comment with your thoughts.

No comments:

Post a Comment