genehenson.com
← Back to blog

Build a Blog: Part 3 - Dark Mode

©genehenson10 Feb 20215 minute read

I'm not going to insult your intelligence by explaining what dark mode is or why you want to use it. Let's just get straight into figuring out how to implement it.

General approach

There are many ways to implement dark mode. Personally, I like the approach that YouTube takes. They put an attribute on their HTML element like this:

<html dark="true" ...>
  ...
</html>

They just a simple dark="true" to indicate that they are in dark mode. Then they use css custom properties to determine colors based on that attribute:

html:not(.style-scope)[dark] {
  --yt-live-chat-action-panel-background-color: #282828;
  --yt-live-chat-secondary-background-color: #282828;
  --yt-live-chat-toast-text-color: var(--yt-white);
}

This approach will work nicely with the way that I've structured my css and, in theory, it should be easy enough to add/remove an attribute from the HTML document.

I want to make sure that users are able to manually select light/dark mode and that their choice is saved in the browser. If they haven't make the choice, then it should follow the prefers-color-scheme: dark media query.

It seems pretty simple, so let's get started.

Creating the CSS

Since I've already setup the css system to use custom properties, it's really just a matter of creating some new properties for light/dark mode. I want to allow the option to create more themes in the future, so I'm checking the string value of the a color property. In the future, can expand this to something like color="theme1-light", color=theme-dark, etc.

But, for the foreseeable future, I'm only dealing with light and dark.

:root[color='light'] {
  --body-color: var(--white);
  --text-color: var(--dark-grey);
  --shadow-color: var(--dark);
  --box-color: var(--white);
  --icon-color: var(--orange);
  --surface: var(--light);
  --brightness: 100%;
  --link-color: var(--orange);
}
:root[color='dark'] {
  --body-color: var(--dark);
  --text-color: var(--light-grey);
  --shadow-color: var(--light);
  --box-color: var(--white);
  --icon-color: var(--blue);
  --surface: var(--grey);
  --brightness: 60%;
  --link-color: var(--primary);
}

body {
  background-color: var(--body-color);
  color: var(--text-color);
  transition: background-color 500ms ease-in-out, color 500ms ease-in-out;
}

img {
  filter: brightness(var(--brightness));
}

For the most part the code is pretty self-explanatory, but there are couple of things to note:

  1. I am not choosing color values directly there. They are set in the variables.css file. This allows me to tweak the color scheme without changing the value in multiple places.
  2. I have a "brightness" prop that's used on the img element. This darkens images in dark mode. Hopefully uses reading this at 2 in the morning will thank me for that.

That's pretty much it for the css. Now I can use the various custom properties wherever I need to set theme specific colors.

Components

The component aspect of this is pretty straight forward, so I'll just show an example.

const PPWrapper = styled.div`
  background-color: var(--surface);
  display: grid;
  transition: background-color 500ms ease-in-out;
`;

This is the code used for the wrappers on the blog page. You see that I set the background color of the div to var(--surface). Since surface is set based on the color prop on my html elements, it just changes whenever it needs to.

Also note that I apply a transition to background-color to make it a bit easier on the eyes when you switch between themes.

This same concept is used for link colors, text color, etc.

The theme toggle

This is where the work really starts. I started with building a simple toggle to switch between light mode and dark mode. When the toggle is clicked, it should:

  1. Set the color attribute on the html element.
  2. Update local storage with the users new preference.

Sounds simple, right?

Let's take a look at the finished component.

import React from 'react';
import styled from 'styled-components';

// Font Awesome
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';

// Hooks
import { useTheme } from '../context/ThemeContext';
import { useSystemColor } from '../hooks/useSystemColor';

const ToggleWrapper = styled.button`
  cursor: pointer;
  background: none;
  padding: 0;
  border: none;
`;

const ToggleButton = styled(FontAwesomeIcon)`
  color: var(--icon-color);
`;

export const ThemeSelector = () => {
  const { theme, setTheme } = useTheme(); // The current theme
  const prefersDarkMode = useSystemColor(); // @media prefers dark mode
  let isDark = false;

  if (!theme) {
    prefersDarkMode ? (isDark = true) : (isDark = false);
  } else {
    isDark = theme === 'dark' ? true : false;
  }

  return (
    <div>
      <ToggleWrapper
        role='button'
        aria-label={`Toggle ${isDark ? 'light' : 'dark'} mode`}
        onClick={setTheme}
      >
        <ToggleButton focusable icon={isDark ? faMoon : faSun} />
      </ToggleWrapper>
    </div>
  );
};

So what the heck is going on here. Well, first we have to import two custom hooks.

  1. useTheme() stores and controls the currently selected theme
  2. useSystemColor gets the prefers-color-scheme: dark media query

The only reason these exist is so that I can show the correct icon and aria-label.

First I check to see whether or not theme has a value. If it does not, then I set isDark based on the users preferred color scheme. If theme has a value then I set isDark based on that value. (Remember, theme is a string value since I may want to have non-binary themes some day.)

Once that's all sorted, I just use a FontAwesome icon to show a sun/moon baed on the current theme. The onClick event of that is sent back to our theme hook, which well get to in a moment.

You might be wondering why we have to check whether or not theme is undefined inside the thing that sets the theme in the first place. Well, it will become clearer later on, but it comes down to fact that these pages are server-side rendered.

Since the rendering happens long before the user opens the webpage in their browser, we have to run some javascript before loading the html, which means the theme initially gets set outside of react.

All in all this component is pretty simple. Just remember keyboard focus and aria labels.

ThemeContext

So this is where the magic happens. I wrap the application in context that holds the current theme. This makes it accessible wherever I need it.

import React, { createContext, useContext, useEffect, useState } from 'react';
import { useLocalStorage } from '../hooks/useLocalStorage';
import { useSystemColor } from '../hooks/useSystemColor';

enum ThemeEnums {
  light = 'light',
  dark = 'dark',
}

type ThemeTypes = ThemeEnums.dark | ThemeEnums.light;

interface Props {
  children: React.ReactNode;
}

interface IContext {
  theme?: keyof typeof ThemeEnums;
  setTheme?: () => void;
}

const LocalStateContext = createContext<IContext>({});
const LocalStateProvider = LocalStateContext.Provider;

export const ThemeContext = ({ children }: Props) => {
  const [theme, setTheme] = useState<ThemeTypes>();
  const prefersDarkMode = useSystemColor();
  const [setValue, getValue] = useLocalStorage();

  useEffect(() => {
    const localTheme = getValue('theme');
    if (localTheme && localTheme in ThemeEnums) {
      // We have to ignore this line becuase the
      // TS compiler doesn't know that we already
      // checked the type of localTheme
      // @ts-ignore
      setTheme(() => localTheme);
    }
  }, []);

  function toggleTheme() {
    if (theme === 'light') {
      setTheme(() => ThemeEnums.dark);
      setValue('theme', ThemeEnums.dark);
    } else if (theme === 'dark') {
      setTheme(() => ThemeEnums.light);
      setValue('theme', ThemeEnums.light);
    } else if (prefersDarkMode) {
      setTheme(() => ThemeEnums.light);
      setValue('theme', ThemeEnums.light);
    } else {
      setTheme(() => ThemeEnums.dark);
      setValue('theme', ThemeEnums.dark);
    }
  }

  return (
    <LocalStateProvider value={{ theme: theme, setTheme: toggleTheme }}>
      {children}
    </LocalStateProvider>
  );
};

export const useTheme = () => {
  const all = useContext(LocalStateContext);
  return all;
};

OK, so what's the idea here? Let me break it down a little more:

Component State

const [theme, setTheme] = useState<ThemeTypes>();
const prefersDarkMode = useSystemColor();
const [setValue, getValue] = useLocalStorage();

This creates local state to hold and set our current theme. I also bring in two custom hooks. useSystemColor() gets the current value of the users prefers-color-scheme: dark. useLocalStorage() handles getting and setting items in local storage.

useEffect

useEffect(() => {
  const localTheme = getValue('theme');
  if (localTheme && localTheme in ThemeEnums) {
    // We have to ignore this line becuase the
    // TS compiler doesn't know that we already
    // checked the type of localTheme
    // @ts-ignore
    setTheme(() => localTheme);
  }
}, []);

This useEffect block runs once when the component is initialized and attempts to get a theme value from local storage. This is makes sure that I load whatever the users last preference is.

Toggle Function

function toggleTheme() {
  if (theme === 'light') {
    setTheme(() => ThemeEnums.dark);
    setValue('theme', ThemeEnums.dark);
  } else if (theme === 'dark') {
    setTheme(() => ThemeEnums.light);
    setValue('theme', ThemeEnums.light);
  } else if (prefersDarkMode) {
    setTheme(() => ThemeEnums.light);
    setValue('theme', ThemeEnums.light);
  } else {
    setTheme(() => ThemeEnums.dark);
    setValue('theme', ThemeEnums.dark);
  }
}

This block is responsible for toggling the them from light to dark. One thing to note here is that it's possible that the user is in light or dark mode without there being a value in local storage. By default, I just follow the OS theme. I don't set anything in local storage until the user has explicitly requested dark or light mode.

useTheme Hook

export const useTheme = () => {
  const all = useContext(LocalStateContext);
  return all;
};

Finally I just export the context in a useTheme hook that can be used wherever needed

Setting the HTML Attribute

Setting the color attribute in HTMl is pretty straightforward. I already have an SEO component that sets a bunch of meta information and sets the lang attribute on the HTML. I can use this to also set the color attribute. I'm just going to show the parts that are relevant to the color scheme.

export const SEO = ({...}: Props) => {
  const theme = useTheme();
  const prefersDarkMode = useSystemColor();
  const htmlColor = theme.theme || (prefersDarkMode && 'dark') || false;

  return (
    <Helmet titleTemplate={`%s ▶️ ${site.siteMetadata.title}`}>
      {htmlColor ? <html lang='en' color={htmlColor} /> : <html lang='en' />}
      ...
    </Helmet>
  );
};

So here I am attempting to get the current theme from useTheme(). There's a chance that is not set, so I also grab the users current system preference with useSystemColor.

The next line sets the color. Since the html can be server-side rendered, prefersDarkMode could be set to nothing (because it's not running in a browser). If that's the case, I just set the value to false and don't render the color attribute to HTML.

FOUC

Everything above works great except for one circumstance. When the HTML is rendered on the server it will also be in light mode since that's the default. That's fine because you have to have some kind of default. But, if a user opens the page in an OS set to dark mode, they will first see the flash of light mode, and then it will run all of the above code and switch it to dark mode. This is an ugly and unfortunate side-effect of SSR apps.

So what can I do about it? The solution isn't too hard really. Basically we can inject some JS into the browser ahead of the application and use that to set a color attribute on the HTML element. Since the theming is all happening in CSS, as long as the HTML is set correctly, the right colors will show before the react application even loads. Here's a simple script that sets this:

This is the function that gets inserted into the page ahead of the react app. Gatsby has an article that describes how to modify the html file.

(function () {
  try {
    const theme = localStorage.getItem('theme');
    if (theme === 'light' || theme === 'dark') {
      document.getElementsByTagName('html')[0].setAttribute('color', theme);
      return;
    }
    const prefersDarkMode =
      window.matchMedia('(prefers-color-scheme: dark)').matches === true;
    if (prefersDarkMode) {
      document.getElementsByTagName('html')[0].setAttribute('color', 'dark');
      return;
    }
    document.getElementsByTagName('html')[0].setAttribute('color', 'light');
    return;
  } catch (e) {}
})();

It just looks in localStorage to see if the use has a value set there. If not, it tries to find a prefers-color-scheme: dark media query. If those both fail, it just sets the color scheme to light.

With this, I now have a functional dark mode.

Closing thoughts

This was a fun implementation but there are definitely some things that could be refined about the process. I didn't do much research before I started implementing it, so the whole FOUC thing really caught me by surprise. I had to go back and change a bunch of the original implementation because of it, so it's kind of bastardized. The whole thing could really use a refactor into something more streamlined.

Share this

You think I got something wrong or missed the point entirely? You're probably right. Hit me up on Twitter and let me know.