How to Implement Dark Mode with Tailwind v2.0

How to Implement Dark Mode with Tailwind v2.0

Featured on Hashnode

Dark mode. This design trend has almost become expected by most users with websites and web applications. Dark mode has been possible to integrate with Tailwind projects, but required some workarounds utilizing CSS variables and the config file got messy and hard to read. With Tailwind v2.0, dark mode is built right into the framework and removes the need to work with those pesky CSS variables!

In this article, I'll show you how to get dark mode working in a React application (stick to the end - I'll give you a starter template!). Even though this is done in React, the main ideas apply to all modern JavaScript frameworks.

Let's get started!

TL;DR

I acknowledge this is a long article, so I'll give you a summary. Tailwind v2.0 gives us the flexibility to choose how we want to implement dark mode. If we want full control, Tailwind will look for an element in the DOM with the dark class attached to it. If the element is found, elements styled with the dark variant will be applied. Here's an example of styling a component with dark mode :

<div class="bg-white dark:bg-gray-800">
  <h1 class="text-gray-900 dark:text-white">Dark mode is here!</h1>
  <p class="text-gray-600 dark:text-gray-300">
    Lorem ipsum...
  </p>
</div>

It's up to us to supply the functionality that dynamically adds/removes the dark class to a top-level document element. In this tutorial, we utilized the Context API to expose a theme variable and a function to change the value. When the value is changed, it triggers a function in a component called ThemeProvider that adds/removes the dark class on the root document element.

Want to see the finished code? Check out my starter template HERE!

Here's a preview of what you get with the template: Tailwind Dark Mode Template

Thinking Through the Problem

Still here? Cool!

I prefer to break features down into manageable tasks before I begin to work on them. It helps me understand requirements, identify and pain points and better think through the problem.

The first thing we should figure out is how Tailwind wants developers to work with the new dark mode feature.

According to the documentation, Tailwind v2.0 now includes a dark variant. This means that you can style your elements as you normally would, and for those elements that you want to alter their appearance in dark mode, you simply add the 'dark' variant in front of the style utilities. You can chain them too, which is pretty neat. Here's a basic example of styling an element with both light and dark mode:

<div class="bg-white dark:bg-gray-800">
  <h1 class="text-gray-900 dark:text-white">Dark mode is here!</h1>
  <p class="text-gray-600 dark:text-gray-300">
    Lorem ipsum...
  </p>
</div>

Styling elements with the dark variant won't actually do anything until dark mode is enabled in the project.

In the tailwind.config.js file, we can include the darkMode option to enable the feature in Tailwind.

It's important to note that there are two values we can use here - media and class.

The media value uses the prefers-color-scheme media feature to detect if the user has dark mode selected as their appearance preference. This is the easiest option and doesn't require any additional set up to get working. Simply set the value to media, styles your elements with the dark variant, and you're good to go!

// tailwind.config.js
module.exports = {
  darkMode: 'media',
  // ...
}

On the other hand, if we want to include a manual appearance switch on the web application, we can use the class value. Since most websites and applications allow us to switch dark mode on or off (usually with a toggle), this is that value we'll use in our example (and we'll manually use prefers-color-scheme just for the fun of it!). Because this option doesn't do any of the heavy-lifting for us, it's our job to implement the toggle functionality.

// tailwind.config.js
module.exports = {
  darkMode: 'class',
  // ...
}

Continuing the thought above, in order for the dark mode styles to take effect, Tailwind searches through the DOM for an element with a dark class applied to it. If it finds that element, all of the dark variant style utilities will be applied.

Now that we understand how Tailwind wants us to work with dark mode and what we need to do to manually switch the theme, let's generate the project!

Generating the React Project & Installing Tailwind

First things first. We need a project to work in. I won't go into detail on how to generate a React project and how to install Tailwind, instead, I'll link you to the official Tailwind documentation.

Enabling Dark Mode

We've hit on this briefly above, but the next step is to actually enable dark mode in our tailwind.config.js file. Make sure you set darkMode to a value of class.

// tailwind.config.js
module.exports = {
  darkMode: 'class',
  // ...
}

Removing Boilerplate Code

Open the App.js file and replace with boilerplate code with the following markup.

import './App.css';

function App() {
  return (
    <div className="px-4 mx-auto max-w-screen-sm md:max-w-screen-md md:p-0 lg:max-w-screen-lg xl:max-w-screen-xl">
      <div className="min-h-screen flex justify-center items-center">
        <h1 className="text-gray-900 dark:text-white text-3xl sm:text-5xl lg:text-6xl leading-none font-extrabold tracking-tight mb-8">Dark Mode Template</h1>
      </div>
    </div>
  );
}

export default App;

We set some max-width breakpoints on the outer div and incorporate the dark variant on the h1 tag so the text color changes to white when dark mode is active.

The ThemeContext Component

We want any part of our application (page, component, etc) to be able to determine the active theme (light or dark). We can use the Context API to achieve this, so let's create a component that encapsulates that logic and exposes the necessary context.

This component will also store the current theme in local storage so the theme is saved for the next time the user visits the app.

Create a new file (I've placed it in a folder named components) called themeContext.js and paste the following code.

import React from 'react';

const getInitialTheme = () => {
    if (typeof window !== 'undefined' && window.localStorage) {
        const storedPrefs = window.localStorage.getItem('color-theme');
        if (typeof storedPrefs === 'string') {
            return storedPrefs;
        }

        const userMedia = window.matchMedia('(prefers-color-scheme: dark)');
        if (userMedia.matches) {
            return 'dark';
        }
    }

    // If you want to use dark theme as the default, return 'dark' instead
    return 'light';
};

export const ThemeContext = React.createContext();

export const ThemeProvider = ({ initialTheme, children }) => {
    const [theme, setTheme] = React.useState(getInitialTheme);

    const rawSetTheme = (rawTheme) => {
        const root = window.document.documentElement;
        const isDark = rawTheme === 'dark';

        root.classList.remove(isDark ? 'light' : 'dark');
        root.classList.add(rawTheme);

        localStorage.setItem('color-theme', rawTheme);
    };

    if (initialTheme) {
        rawSetTheme(initialTheme);
    }

    React.useEffect(() => {
        rawSetTheme(theme);
    }, [theme]);

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

There's a lot here. Let's break it down.

First, we declare and export a variable which holds some state context. We'll import this variable in the Toggle component in a few minutes.

export const ThemeContext = React.createContext();

The getInitialTheme() function looks more complicated than it actually is. This function's job is to check the user's appearance preference, and if they have set it to dark mode, the function returns dark as the value. If the user has not selected dark as their default system appearance, this function returns light. Note that if you want your application to show a dark appearance by default, you can alter this function to return dark.

This function is being called inside the ThemeProvider component itself when we create a slice of state to hold the value of the theme.

export const ThemeProvider = ({ initialTheme, children }) => {
    const [theme, setTheme] = React.useState(getInitialTheme);
...
}

Next, we create a function called rawSetTheme which takes in a theme value as a parameter.

This function grabs the element that is the root element of the document in our React application. It stores this element in a variable named root.

const root = window.document.documentElement;

Next, we create a boolean variable named isDark. This variable needs to determine if the theme that was passed to this function was set to dark. Writing an if statement here is overkill, so instead we can do it all on one line.

const isDark = rawTheme === 'dark';

Now that we have these pieces, we can dynamically remove the old theme class value on the root element, and then replace it with the new theme value! This is important! Remember, Tailwind looks for an element in the DOM that has a dark class applied to it. If that element is found, all of the dark variant styles will be applied. We've just completed this functionality with a few lines of code. Cool, huh?

root.classList.remove(isDark ? 'light' : 'dark');
root.classList.add(rawTheme);

Finally, we store the user's theme value in local storage so that it is saved for the next time the user visits the web app (the getInitialTheme() function above looks for this value and returns the appropriate value).

localStorage.setItem('color-theme', rawTheme);

Next up, outside of the rawSetTheme() function, we create an if statement which checks to see if an initial theme value was passed to the component. If so, we send this value straight to the rawSetTheme() function and it takes care of the rest.

if (initialTheme) {
  rawSetTheme(initialTheme);
}

What comes next is super cool. We incorporate a useEffect hook in the component which calls the rawSetTheme() function whenever the theme variable is changed. The real power of this comes into play later when other components want to change the value of the theme.

React.useEffect(() => {
    rawSetTheme(theme);
}, [theme]);

Finally, we have the mandatory return statement which renders the elements passed as children to this component and wraps them in ThemeContext.Provider so all of the children have access to theme and setTheme.

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

Again, now that other components have access to the theme variable and when they change the value, the rawSetTheme() function inside of this component gets triggered. Are you starting to see the power of this? Good!

Creating the Toggle

Next, create a new file called themeToggle.js and set up the basic component structure.

import React from 'react';

const Toggle = () => {
    return (
        <div>
            ...
        </div>
    )
}

export default Toggle;

Because we've exposed some state with the Context API, we can access the state in this component.

First, import ThemeContext (which is what we exported in the previous file), pass it to the useContext hook and grab theme and setTheme from the context.

import { ThemeContext } from './themeContext';

const Toggle = () => {
    const { theme, setTheme } = React.useContext(ThemeContext);
...
}

Now we can render icons dynamically depending on the theme value. When an icon is clicked, call the setTheme() function and pass the appropriate value.

NOTE: I've installed react-icons and am using the sun and moon icons from Hero Icons

Here is the full Toggle component.

import React from 'react';
import { HiMoon, HiSun } from 'react-icons/hi';
import { ThemeContext } from './themeContext';

const Toggle = () => {
    const { theme, setTheme } = React.useContext(ThemeContext);

    return (
        <div className="transition duration-500 ease-in-out rounded-full p-2">
            {theme === 'dark' ? (
                <HiSun
                    onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
                    className="text-gray-500 dark:text-gray-400 text-2xl cursor-pointer"
                />
            ) : (
                    <HiMoon
                        onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
                        className="text-gray-500 dark:text-gray-400 text-2xl cursor-pointer"
                    />
                )}
        </div>
    );
};

export default Toggle;

Putting it all Together

Now that we have the themeProvider and Toggle components, let's use them in the index.js file.

ReactDOM.render(
  <React.StrictMode>
    <ThemeProvider>
      <main>
        <div className="absolute right-0 top-0 mr-4 mt-4 md:mr-6 md:mt-6">
          <Toggle />
        </div>
        <App />
      </main>
    </ThemeProvider>
  </React.StrictMode>,
  document.getElementById('root')
);

We want the toggle component to be positioned in the top-right corner of the browser, so we use some absolute position Tailwind utilities to get this working.

The text color should change at this point, but what's the point if the background doesn't change? Let's go ahead and generate a new component that controls the color of the background.

Generate a new file called background.js and paste the following code:

const Background = ({children}) => {
    return (
        // Remove transition-all to disable the background color transition.
        <body className="bg-white dark:bg-black transition-all">
            {children}
        </body>
    )
}

export default Background;

Head back to the index.js file and wrap the <main> tag inside the new Background component.

ReactDOM.render(
  <React.StrictMode>
    <ThemeProvider>
      <Background>
        <main>
          <div className="absolute right-0 top-0 mr-4 mt-4 md:mr-6 md:mt-6">
            <Toggle />
          </div>
          <App />
        </main>
      </Background>
    </ThemeProvider>
  </React.StrictMode>,
  document.getElementById('root')
);

Now you can run the app using npm start and see the background change from black to white when the toggle is clicked! Nice!

Conclusion

Tailwind v2.0 gives us the flexibility to choose how we want to implement dark mode. In this example, we utilized the Context API to expose a theme variable and a function to change the value. When the value is changed, it triggers a function in the ThemeProvider component that adds a class to the root element. If Tailwind sees a dark class in the DOM, the dark variant styles are applied to your elements. Super cool!

We've covered a lot of ground in this article and if you made it all of the way through, make sure to let me know in the comments down below. You're the real MVP!

Before wrapping up, I want to let you know about a gift I made for you! I've created a React starter template - Tailwind v2.0 is set up and ready-to-go with dark mode support!

You can clone the repo HERE!

Here's a preview of what you get with the template: Tailwind Dark Mode Template

Thanks for reading! If you liked this article and want more content like this, read some of my other articles , subscribe to my newsletter and make sure to follow me on Twitter!

Did you find this article valuable?

Support Braydon's Blog by becoming a sponsor. Any amount is appreciated!