Will's avatar

⬅️ See more posts

Adding 'dark mode' and dynamic theming to your React websites

21 August 2021 (8 minute read)

🔮 This post is also available via Gemini.

100daystooffload technology javascript react

💯 100 Days to Offload

This article is one of a series of posts I have written for the 100 Days to Offload challenge. Disclaimer: The challenge focuses on writing frequency rather than quality, and so posts may not always be fully planned out!

View other posts in this series.

AI generated artwork representation of website theming.

Adding theming and the choice between “light” and “dark” modes to your website can enhance your site’s accessibility and make it feel more consistent with the host operating system’s own theme.

With JavaScript (and React in particular) this is easy to do, as I’ll explain in this post. We’ll use a combination of our own JavaScript code, the Zustand state management library, and the styled components package to achieve the following:

  • Create a theme library
  • Dynamically update the site’s styles based on the theme
  • Allow the user to change the theme
  • Remember the theme for the next time the user visits
  • Detect the operating system dark/light mode to auto-set an appropriate theme

This post is written from the perspective of a GatsbyJS website, however the same concepts should apply to any React single-page application.

TL;DR: If you just want the code, scroll to the ‘Complete code’ section at the bottom of the post.

Setting up the theming

To start, add these new packages to the project:

yarn add zustand styled-components

Next, declare some themes! For this you can create a themes.js file (e.g. in your top level src/ directory):

const themes = {
  light: {
    name: '☀️ Light Theme',
    background: '#FFFFFF',
    text: '#000000',
    headers: '#000000'
    links: '#01BAEF',
  },
  dark: {
    name: '🌒 Dark Theme',
    background: '#0F0E17',
    text: '#A7A9BE',
    headers: '#FFFFFE',
    links: '#FF8906',
  },
};
export default themes;

You can add as many attributes to your themes as you like in order to increase their flexibility. I use Happy Hues for inspiration. You can also create as many different themes as you like!

Now we need a way to manage the themes and to maintain the currently-selected theme. This is where the Zustand library will come in useful for managing the app’s global theme state.

For this, create a store.js file (e.g. again, this could be in your site’s top-level src/ directory) with these contents:

import create from 'zustand';

export default create(set => ({
  theme: 'light',
  setTheme: theme => set({ theme }),
}));

This tells Zustand to create a new store with two attributes: a reference to the current theme key (defaulting to light) and a function for changing the theme.

Next, use the styled-components library to create some dynamic components for use within the app that change their style based on the currently selected theme.

The package’s createGlobalStyle function is great for injecting styles that affect the entire app. Import the function at the top of your primary layout file:

import { createGlobalStyle } from 'styled-components';

And then use it to create a global style component for your website based on the currently-selected theme (in the same top-level layout file):

const GlobalStyle = createGlobalStyle`
  body{
    background-color: ${({ theme }) => theme.background};
    color: ${({ theme }) => theme.text};
    transition: background-color 0.25s, color 0.25s;
  }
  h1,h2,h3,h4 {
    color: ${({ theme }) => theme.headers};
  }
  a {
    color: ${({ theme }) => theme.links};
  }
`;

We also added a transition to fade nicely between styles when the user later switches theme.

Finally, add some code (again, to the same layout file) to import the current theme from the store and pass this to the global style component we created.

// First import the store you created earlier near the top of the file:
import useStore from '../../store';

// Import the themes from the theme file you created earlier:
import themes from '../../themes';

...

// In your main layout component load the theme from the store:
const LayoutComponent = ({ children }) => {
  const theme = useStore(s => s.theme);

  ...
  
  // Then render the global styles and the layout's children:
  return (
    <div>
      <GlobalStyle theme={themes[theme]} />
      <div>{children}</div>
    </div>
  );
}

Your website is now using the theme to set the colours you defined in your theme object. If you change the default theme (in the store.js file) to dark and refresh the page, you should see the styles for the dark theme rendered.

Changing the theme

We now need to provide a way for the user to change the website’s theme. Typically this might be done in a dedicated component elsewhere in your app, which is where using the Zustand store comes in handy.

Currently, on this website, I have a change theme select box in the site’s Header component (as shown in the image below):

The change theme select box on my website

This can be achieved through something like the following:

// Import the styled-components library:
import styled from 'styled-components';

// Import the store we created earlier:
import useStore from '../store';

// Import the theme list we created earlier:
import themes from '../themes';

// Use styled-components to create a nice select box:
const StyledThemeSelector = styled.select`
  padding: 4px;
  background: white;
  border: 1px solid gray;
  border-radius: 5px;
  cursor: pointer;
`;

// In the component load the store:
const Header = () => {
  const store = useStore();

  // When rendering the component, include your nicely styled theme selector:
  return ( <>
    ...
    <StyledThemeSelector value={store.theme} onChange={e => store.setTheme(e.target.value)}>
      {Object.keys(themes).map(themeName =>
        <option key={themeName} value={themeName}>{themes[themeName].name}</option>
      )}
    </StyledThemeSelector>
    ...
  </> );
}

Now, when you switch theme using the select box, the entire app should change to reflect the new styles. Also, because you added the transition to the styles earlier, the new colours should fade in nicely rather than suddenly changing.

Of course, the theme-selctor can be any type of component - a button, a list of radio buttons, or anything else. If you have lots of themes you could even create a more visual theme gallery.

Remembering the selected theme

You can configure your website to remember the user’s theme choice by leveraging localStorage.

To do so, first update your theme-selection component (as above) to implement a function which updates the site’s local storage each time the user changes the theme:

...
const Header = () => {
  const store = useStore();

  // Add this function:
  const switchTheme = themeName => {
    store.setTheme(themeName);
    localStorage.setItem('theme', themeName);
  };

  // And update the onChange for your theme selector:
  return ( <>
    ...
    <StyledThemeSelector value={store.theme} onChange={e => switchTheme(e.target.value)}>
      ...
    </StyledThemeSelector>
    ...
  </> );
}

Then, back in your top-level layout file, add a useEffect hook to update the theme on app-load:

// Add the useEffect import:
import React, { useEffect } from 'react';
...

const LayoutComponent = ({ children }) => {
  const setTheme = useStore(s => s.setTheme);
  ...

  useEffect(() => {
    // Load and set the stored theme
    const rememberedTheme = localStorage.getItem('theme');
    if (rememberedTheme && themes[rememberedTheme]) {
      setTheme(rememberedTheme);
    } 
  }, [setTheme]);
   ...
}

Now the website will load the user’s preferred theme each time they come back to your site.

Automatically matching the operating system theme

Many operating systems allow the user to choose between a light and dark theme, or even to dynamically change the theme based on the time of day.

It can be seen as good practice for websites to display a theme which more closely matches the background OS style to provide a more consistent user experience.

Luckily, this is very easy to do. Just update the useEffect block described above to check for the OS ‘dark mode’ as the app loads.

...
  useEffect(() => {
    const rememberedTheme = localStorage.getItem('theme');
    if (rememberedTheme && themes[rememberedTheme]) {
      setTheme(rememberedTheme);
    } else {
      const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
      if (isDarkMode) setTheme('dark');
    }
  }, [setTheme]);
...

The nice thing about this approach is that if the user has already chosen another theme preference, this will be used instead of the default.

Complete code

I hope this post helps you to create your own theme libraries and nice style-switchers. As mentioned earlier, you could create as many themes as you like and make them as complex as necessary.

You can also import the store into any component if you want more fine-grained control (e.g. themed message boxes), and even include fonts and other types of styles.

For the complete code discussed in this post, see below.

/src/themes.js

const themes = {
  light: {
    name: '☀️ Light Theme',
    background: '#FFFFFF',
    text: '#000000',
    headers: '#000000'
    links: '#01BAEF',
  },
  dark: {
    name: '🌒 Dark Theme',
    background: '#0F0E17',
    text: '#A7A9BE',
    headers: '#FFFFFE',
    links: '#FF8906',
  },
};
export default themes;

/src/store.js

import create from 'zustand';

export default create(set => ({
  theme: 'light',
  setTheme: theme => set({ theme }),
}));

Top-level layout/app file

import React, { useEffect } from 'react';
import { createGlobalStyle } from 'styled-components';
import useStore from '../../store';
import themes from '../../themes';

const GlobalStyle = createGlobalStyle`
  body{
    background-color: ${({ theme }) => theme.background};
    color: ${({ theme }) => theme.text};
    transition: background-color 0.25s, color 0.25s;
  }
  h1,h2,h3,h4 {
    color: ${({ theme }) => theme.headers};
  }
  a {
    color: ${({ theme }) => theme.links};
  }
`;

const LayoutComponent = ({ children }) => {
  const theme = useStore(s => s.theme);
  const setTheme = useStore(s => s.setTheme);
  ...
  useEffect(() => {
    const rememberedTheme = localStorage.getItem('theme');
    if (rememberedTheme && themes[rememberedTheme]) {
      setTheme(rememberedTheme);
    } else {
      const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
      if (isDarkMode) setTheme('dark');
    }
  }, [setTheme]);
  ...
  return (
    <div>
      <GlobalStyle theme={themes[theme]} />
      <div>{children}</div>
    </div>
  );
}
export default LayoutComponent;

Another component (or wherever you want your theme-switcher to be)

import React from 'react';
import styled from 'styled-components';
import useStore from '../store';
import themes from '../themes';

const StyledThemeSelector = styled.select`
  padding: 4px;
  background: white;
  border: 1px solid gray;
  border-radius: 5px;
  cursor: pointer;
`;

const Header = () => {
  const store = useStore();

  const switchTheme = themeName => {
    store.setTheme(themeName);
    localStorage.setItem('theme', themeName);
  };

  return ( <>
    ...
    <StyledThemeSelector value={store.theme} onChange={e => switchTheme(e.target.value)}>
      {Object.keys(themes).map(themeName =>
        <option key={themeName} value={themeName}>{themes[themeName].name}</option>
      )}
    </StyledThemeSelector>
    ...
  </> );
}
export default Header;
✉️ You can reply to this post via email.

📲 Subscribe to updates

If you would like to read more posts like this, then you can subscribe via RSS.