Infoxicator.com

Dark Mode Is Not Enough! Here is an alternative…

Published
cover image

These days most websites have an option to toggle Dark mode, and if you find one without it, you will be screaming: “How dare you burn my retinas!”. But what if I wanted more than a light and a dark colour scheme and you had the option to use “Gray Mode”, or “Christmas Mode” or “My Favorite movie/video game mode”?

Creating a Multi theme Switcher with React

100543720-ad835980-3249-11eb-826f-296b559f1837.gif (1307×431)

Here are the features I am looking for:

  • Switch between an infinite number of themes
  • The current theme should be available to all react components in the application.
  • Default Dark and Light modes depending on the user’s Operating System or browser preference.
  • The chosen theme should be persisted on the user’s browser
  • No “Flash of Death” on hard refresh for static rendered sites

For this tutorial, I will be using Next.js but if you are using Gatsby, there is a nice present for you at the end of the article 😉

Let’s start with the standard Next.js blog template that comes with Tailwind included, however, this solution should work with any styling library of your choice including styled-components and CSS Modules.

npx create-next-app --example blog-starter blog-starter-app 

Adding Theme Colours

We are going to use CSS variables to add colours to our site and a global CSS class to set our theme.

Open your index.css file and add a new class for every theme that you want to add, for example:

.theme-twitter {
    --color-bg-primary: #15202B;
    --color-bg-primary-light: #172D3F;
    --color-bg-accent: #1B91DA; 
    --color-bg-accent-light: #1B91DA; 
    --color-bg-secondary: #657786;
    --color-text-link: #1B91DA;    
    --color-bg-compliment: #112b48;
    --color-bg-default: #192734;
    --color-bg-inverse: #1B91DA;
    --color-text-primary: #fff;
    --color-text-secondary: #f2f2f2;
    --color-text-default: #e9e9e9;
    --color-text-default-soft: #6a6a6a;
    --color-text-inverse: #1B91DA;
    --color-text-inverse-soft: #1B91DA;
  }

.theme-midnightgreen {
  --color-bg-primary: #004953;
  --color-bg-primary-light: #E7FDFF;
  --color-bg-accent: #DE7421; 
  --color-bg-accent-light: #DE7421; 
  --color-bg-secondary: #E7FDFF;
  --color-text-link: #008ca0;
  --color-bg-compliment: #f5f5ff;
  --color-bg-default: #f5f5f5;
  --color-bg-inverse: #d77d4d;
  --color-text-primary: #f5f5f5;
  --color-text-secondary: #004953;
  --color-text-default: #303030;
  --color-text-default-soft: #484848;
  --color-text-inverse: #008ca0;
  --color-text-inverse-soft: #ffffffb3;
}

.theme-my-favourite-colors {
 ...
}

Open your tailwind.config.js file and extend the colour classes with the CSS variables that you created in the previous step. Example:

module.exports = {
  purge: ['./components/**/*.js', './pages/**/*.js'],
  theme: {
    extend: {
      colors: {
        'accent-1': 'var(--color-bg-primary)',
        'accent-2': 'var(--color-bg-secondary)',
        'accent-7': 'var(--color-bg-accent)',
        success: '#0070f3',
        cyan: '#79FFE1',
      },
      textColor: {
        white: "var(--color-text-primary)",
        grey: "var(--color-text-link)",
        black: "var(--color-text-secondary)",
      },
    },
  },
}

Note: If you are not using Tailwind, you can configure your styling solution using the same CSS variables, the rest of the steps in this tutorial should remain the same.

Assign the CSS class to the document body tag to apply your custom styles. Open your _document.js file and add hardcode your default theme for now

<body className="theme-twitter">
  <Main />
  <NextScript />
</body>

Refresh the page and you should see the theme colours for the class that you have selected.

Theme State

To manage state, make the theme available globally to all our components and switch between different themes; we are going to use the React Context API to create a theme context and provider.

Create a new file under context/theme-context.js

import React from "react";
import useLocalStorage from "./context/use-local-storage";

const ThemeContext = React.createContext();

export const ThemeProvider = ({ children }) => {
  const [theme, setTheme] = useLocalStorage("theme", null);
  const switchTheme = (newTheme) => {
    // eslint-disable-next-line no-undef
    const root = document.body;
    root.classList.remove(theme);
    root.classList.add(newTheme);
    setTheme(newTheme);
  };
  return (
    <ThemeContext.Provider value={{ theme, switchTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

export default ThemeContext;

I am using the useLocalStorage hook to persist the theme value under the “theme” key. The source code for this hook can be found here: https://github.com/infoxicator/use-theme-switcher/blob/master/src/use-local-storage.js

The initial value will be null if local storage is empty, more on this later.

The switchTheme hook will replace the value of the CSS class we added to the body with the new value passed to this function as well as persisting the value in Local Storage.

Add the new provider to _app.js

import '../styles/index.css'
import { ThemeProvider } from '../context/theme-context';

export default function  MyApp({ Component, pageProps }) {
  return <ThemeProvider>
      <Component {...pageProps} />
    </ThemeProvider>
}

Theme Picker

Let’s create a very basic theme picker component that will toggle between the available themes.

import React from "react";

const myThemes = [
    {
        id: "theme-midnightgreen",
        name: "Midnight Green",
    },
    {
        id: "theme-spacegray",
        name: "Space Gray",
    },
    {
        id: "theme-twitter",
        name: "Twitter Dark",
    }
]

const ThemePicker = ({ theme, setTheme }) => {
    if (theme) {
        return (
            <div>
            {myThemes.map((item, index) => {
                const nextTheme = myThemes.length -1 === index ? myThemes[0].id : myThemes[index+1].id;
                
                return item.id === theme ? (
                    <div key={item.id} className={item.id}>
                    <button
                        aria-label={`Theme ${item.name}`}
                        onClick={() => setTheme(nextTheme)}
                    >
                        {item.name}
                    </button>
                    </div>
                ) : null;
                    }
                )}
            </div>
        );
    }
    return null;
};

export default ThemePicker;

This component, will take an array of available themes and render a button that will set the next available theme on click. This is a very basic implementation of the theme switcher component, but you can add your custom logic and design, like selecting from a drop down or rendering a list instead.

Render the ThemeSwitcher component at the top of the site. Open layout.js and add the following:

import ThemePicker from './theme-picker';
import React, { useContext } from "react"
import ThemeContext from '../context/theme-context';

export default function Layout({ preview, children }) {
  const { theme, switchTheme } = useContext(ThemeContext);
  return (
    <>
      <Meta />
      <div className="min-h-screen bg-accent-1 text-white">
        <Alert preview={preview} />
        <ThemePicker theme={theme ? theme : 'theme-midnightgreen'} setTheme={switchTheme} />
        <main>{children}</main>
      </div>
      <Footer />
    </>
  )
}

The theme value is null for the first time and when the user hasn’t selected a custom theme yet, for that reason we are passing the default theme value to the ThemePicker component.

Overcoming the “White Flash Of Death”

Who would have thought that a simple bug like this would be so complex and so deeply connected to the different ways of rendering websites (Server Side Rendering, Static Site Generation, Client Side Rendering)? In a nutshell, the flash is caused by the timing when the initial HTML is rendered. When we use SSR or SSG with tools like next.js or gatsby, the HTML is rendered ahead of time before it reaches the client, so the initial theme value that comes from local storage will be different from the value that was rendered on the server producing a small “flash” while the correct theme is applied.

The key to fixing this problem is to use a “render blocking” script that will set the correct CSS class before the site content is rendered to the DOM.

Create a new file called theme-script.js

import React from "react";

function setColorsByTheme(
  defaultDarkTheme,
  defaultLightTheme,
  themeStorageKey
) {
  var mql = window.matchMedia("(prefers-color-scheme: dark)");
  var prefersDarkFromMQ = mql.matches;
  var persistedPreference = localStorage.getItem(themeStorageKey);
  var root = document.body;
  var colorMode = "";

  var hasUsedToggle = typeof persistedPreference === "string";

  if (hasUsedToggle) {
    colorMode = JSON.parse(persistedPreference);
  } else {
    colorMode = prefersDarkFromMQ ? defaultDarkTheme : defaultLightTheme;
    localStorage.setItem(themeStorageKey, JSON.stringify(colorMode));
  }

  root.classList.add(colorMode);
}

const ThemeScriptTag = () => {
  const themeScript = `(${setColorsByTheme})(
        'theme-twitter',
        'theme-midnightgreen',
        'theme',
      )`;
// eslint-disable-next-line react/no-danger
  return <script dangerouslySetInnerHTML={{ __html: themeScript }} />;
};

export default ThemeScriptTag;

This script should render before the rest of the content and “block” the rendering of the application until the value of the theme is determined. Add it to your _document.js

import Document, { Html, Head, Main, NextScript } from 'next/document'
import ThemeScriptTag from '../components/theme-script';

export default class MyDocument extends Document {
  render() {
    return (
      <Html lang="en">
        <Head>
        </Head>
        <body>
          <ThemeScriptTag />
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

If you want to dive deep into this issue and this solution, Josh W. Comau created a brilliant blog post analysing this issue step by step and coming up with this solution.

Additional Resources

NPM Package

I have created an npm package that wraps the components created in this tutorial so it is easier to add to your Next.js site:

https://www.npmjs.com/package/use-theme-switcher

Gatsby Plugin

Gatsby and its great plugin system makes it super easy to add this theme switcher by installing:

https://www.npmjs.com/package/gatsby-plugin-theme-switcher

Conclusion

You can now reward your users with new customisable themes to your site, you could even create really creative theme switcher components like the one @SamLarsenDisney added to his site sld.codes… look out for those easter eggs! 😉


Recent Posts

Is React going anywhere?

Earlier this year I had an interesting conversation with a CTO of a price comparison website (e-commerce) and he mentioned that they are moving away from React. “Wait, what?”… was my Reaction (pun 👊 intended)… please tell me more! “Yeah, it is not working for us, we are moving away for performance reasons, e-commerce is[…]

React Router 6 Deferred Fetch

React Router 6 introduced the “deferred” API that allows you to “await” for critical data and “defer” optional data when calling your loaders.

React Router 6.4 Code-Splitting

Single Page Applications that are unable to migrate can benefit from all of the goodies 🎁 that Remix provides.