We’ve all been there, right? It’s 2 AM, you're deep in the zone, and your screen feels like you’re staring directly into the sun. That blinding white background isn’t just harsh on the eyes—it’s a total vibe killer. This is where dark mode comes in, not as some trendy gimmick, but as a genuinely thoughtful feature that makes your app a pleasure to use.
And let's be real, it just looks cool.
In this React dark mode tutorial, we're going to build something more than just a simple on/off button. We’re going to create a smart, persistent, and honestly beautiful dark theme for a React app, all powered by the magic of Tailwind CSS. We'll cover everything from the initial setup to remembering a user's choice and making the whole thing feel buttery smooth with transitions.
By the end of this, you won't just know how to slap a dark mode onto an app; you'll understand why each piece of the puzzle works the way it does. You'll be a dark mode champion, ready to save your users' retinas.
Prerequisites: What You Need Before We Start
Now, I'm not going to assume you're a complete beginner, but you definitely don't need to be a grizzled veteran either. Here’s what you should probably have under your belt:
- Basic knowledge of React: You should be comfortable with what components are and have a passing familiarity with hooks (especially
useStateanduseEffect). We'll walk through them, but it helps if you've seen them before. - Node.js and npm/yarn installed: This is pretty standard for any modern web dev. We'll need it to get our React app off the ground and manage our packages.
- A healthy dose of curiosity: Seriously. The best features always come from asking "what if?" and "how does that work?"
That's it. No need to be a Tailwind CSS wizard—I'll explain everything as we go.
Setting Up Your React Project with Tailwind CSS
First things first, we need a playground. Let's get a fresh React app up and running with Tailwind CSS configured and ready for our dark theme magic.
Creating a New React App
Pop open your terminal and let's use Create React App to bootstrap our project. I'm calling mine react-dark-mode, but you can name it whatever you like.
npx create-react-app react-dark-mode
cd react-dark-mode
Easy enough, right? Now we have a standard-issue React application. Let's go ahead and sprinkle in some Tailwind goodness.
Installing and Configuring Tailwind CSS for Dark Mode
We need to install Tailwind CSS and a couple of its friends. Run this command in your project's root directory:
npm install -D tailwindcss postcss autoprefixer
Next up, we need to generate our configuration files, tailwind.config.js and postcss.config.js.
npx tailwindcss init -p
This is a critical step. These two files are basically the control center for Tailwind in our project. Go ahead and open tailwind.config.js. This is where we'll tell Tailwind which files to watch for class names and—most importantly for us—how to handle dark mode.
Replace the contents of your tailwind.config.js with this:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
darkMode: 'class', // This is the magic line!
theme: {
extend: {},
},
plugins: [],
}
The most important part of this whole file is that little line: darkMode: 'class'. We’ll dive deep into what this means in a moment, but for now, just know this is what gives us manual, fine-grained control over when the dark theme is applied.
Finally, we need to tell our app to actually use Tailwind's styles. Head over to your ./src/index.css file, delete everything inside, and add these three lines:
@tailwind base;
@tailwind components;
@tailwind utilities;
And with that, our setup is complete! Your React app is now officially powered by Tailwind CSS. If you run npm start, you should see the default React page, but now all the default styling is gone, replaced by Tailwind's foundational styles. It's a beautiful blank canvas.
Understanding Tailwind CSS Dark Mode Strategies
Before we write a single line of component code, we have to talk strategy. Tailwind gives us two main ways to handle dark mode: media and class.
The dark: Variant Explained
Tailwind's whole philosophy is built on these handy utility classes. For dark mode, it gives us a special modifier, or variant, called dark:. It's wonderfully simple: you write a base style, and then you write an overriding style prefixed with dark:.
For example:
<div class="bg-white text-black dark:bg-gray-900 dark:text-white">
<!-- This content will have a white background in light mode and a dark gray background in dark mode. -->
</div>
See how intuitive that is? It keeps your styling logic right there in your markup, which is the whole point of Tailwind. But how does Tailwind know when to apply those dark: styles? That’s where the strategy comes in.
Choosing the 'class' Strategy for Our React Dark Theme
-
The
mediaStrategy (darkMode: 'media'): This is the default setting. It automatically appliesdark:styles based on the user's operating system setting (prefers-color-scheme). It's simple, requires zero JavaScript, but it's a bit of a blunt instrument. You can't let the user override it. If their OS is in light mode, your site is in light mode. Period. -
The
classStrategy (darkMode: 'class'): This is the one we chose. It tells Tailwind: "Hey, only applydark:styles when you see adarkclass on a parent element—specifically, the<html>tag."
I like to think of it like a car's transmission. The media strategy is an automatic—it does the work for you, but you have no real control. The class strategy is a manual transmission—it gives us, the developers, full control to decide exactly when to shift into dark mode.
By adding or removing that dark class from the <html> element with a little JavaScript, we can toggle the entire theme on and off whenever we want. This is precisely what we need to build a user-controlled toggle button.
Building the Dark Mode Toggle Component in React
Alright, theory's over. Let's get our hands dirty and build the actual toggle switch. We'll create a new component, manage its state with a React hook, and even style it to look like a sun and a moon.
Create a new file in your src folder (maybe inside a components subfolder, if you're feeling organized) called ThemeToggle.js.
Let's start with the basic structure and state management. We’ll lean on the useState and useEffect hooks for this.
// src/components/ThemeToggle.js
import React, { useState, useEffect } from 'react';
import { SunIcon, MoonIcon } from '@heroicons/react/24/solid';
const ThemeToggle = () => {
// We'll get the theme from localStorage later. For now, default to 'light'.
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
useEffect(() => {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [theme]);
return (
<button
onClick={toggleTheme}
className="p-2 rounded-full bg-gray-200 dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none"
>
{theme === 'light' ? (
<SunIcon className="w-6 h-6" />
) : (
<MoonIcon className="w-6 h-6" />
)}
</button>
);
};
export default ThemeToggle;
Whoa, okay, let's break that down. I promise it's simpler than it looks.
- Icons: First up, I'm pulling in some slick icons from
@heroicons/react. You'll need to install it first:npm install @heroicons/react. These give us a professional-looking sun and moon without us having to fuss with SVGs. - State Management (
useState): The lineconst [theme, setTheme] = useState('light');is the absolute heart of our component. It creates a piece of state calledthemeand gives us a function,setTheme, to update it. We're starting with a default value of'light'. - The Toggle Function:
toggleThemeis our simple little workhorse. When it's called (by clicking our button), it just checks the currenttheme. If it's'light', it sets it to'dark', and vice versa. Classic ternary operator magic. - The Magic Hook (
useEffect): This is where we reach out from React and touch the DOM. TheuseEffecthook runs after every render, but that[theme]at the end is a dependency array—it tells React to only re-run this effect when ourthemestate variable changes.- Inside the effect, we check our
themestate. - If it’s
'dark', we find the main<html>tag (document.documentElement) and slap thedarkclass on it. - If it's
'light', we remove thatdarkclass. - This is the beautiful, direct link between our React state and Tailwind's
classstrategy.
- Inside the effect, we check our
- Styling the Button: We're using a simple
<button>. TheclassNameis packed with Tailwind utilities. Notice thedark:prefixes?bg-gray-200 dark:bg-gray-800. This means the button itself will change color right along with the theme. We also conditionally render either theSunIconor theMoonIconbased on the current theme.
Now, let's pop this component into our App.js file to see it in action!
// src/App.js
import ThemeToggle from './components/ThemeToggle';
function App() {
return (
<div className="min-h-screen bg-white dark:bg-gray-900 transition-colors duration-300">
<div className="flex justify-end p-4">
<ThemeToggle />
</div>
<div className="container mx-auto px-4">
<h1 className="text-4xl font-bold text-gray-800 dark:text-white">
Welcome to the Dark Side!
</h1>
<p className="mt-4 text-gray-600 dark:text-gray-300">
Click the toggle to see the magic happen.
</p>
</div>
</div>
);
}
export default App;
Go ahead, try it! Click that button. The entire page should flip between a light and dark theme. That feeling? That's the power you now wield. Pretty cool, huh?
Applying Dark Theme Styles Across Your Application
Okay, a toggle is cool, but the real test is styling actual components. This is where Tailwind's dark: variant truly shines. It's less of a rigid process and more of a mindset you get into.
Global Dark Mode Styling
I actually already sneaked this into the App.js example. The main container div has these classes: bg-white dark:bg-gray-900. This sets a global background color for light mode and a different one for dark mode. Simple as that.
I also added transition-colors duration-300. This isn't strictly necessary for dark mode to work, but man, does it make the change feel smoother and more professional. Trust me, it's these little details that really elevate a UI.
Component-Specific Dark Mode with Tailwind CSS
Let's imagine a simple card component. In light mode, it has a subtle shadow and a crisp white background. In dark mode, we probably want it to have a dark gray background and maybe a faint white border to help it pop off the page.
Here's how you'd do it:
const Card = ({ title, children }) => {
return (
<div className="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-6 my-4 border border-transparent dark:border-gray-700">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
{title}
</h3>
<p className="text-gray-700 dark:text-gray-300">
{children}
</p>
</div>
);
};
Just look at how readable that is.
bg-white dark:bg-gray-800: White background, but dark gray in dark mode.text-gray-900 dark:text-white: Nearly black text, but bright white in dark mode.border-transparent dark:border-gray-700: An invisible border in light mode becomes a subtle gray one in dark mode just to create some separation.
This is the workflow. As you build your components, you simply ask yourself: "What should this look like in light mode? Okay, and what about dark mode?" Then you just add the corresponding dark: classes. It becomes second nature surprisingly fast.
Making Dark Mode Persistent with Local Storage
Ah, but there's one glaring problem with our setup so far. If you switch to dark mode and then refresh the page... poof. It's back to light mode. The browser has no memory of the user's choice.
Let's fix that with localStorage, a simple key-value store that's built right into every browser.
Saving the User's Theme Choice
We need to make a small tweak to our ThemeToggle.js component. Every time the theme changes, we'll just save the new value to localStorage. We can do this right inside our existing useEffect hook.
// src/components/ThemeToggle.js (updated useEffect)
useEffect(() => {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark'); // Save to localStorage
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light'); // Save to localStorage
}
}, [theme]);
That was easy. Now, whenever our theme state changes, we're not only updating the DOM class, but we're also writing that choice to localStorage under the key 'theme'.
Loading the Theme on App Initialization
Of course, saving is only half the battle. We also need to read from localStorage when the component first loads to see if the user already has a saved preference.
We'll modify our useState initializer to take a function. This function will run only once when the component first mounts. It will try to get the theme from localStorage, and if it finds nothing, it will default back to 'light'.
// src/components/ThemeToggle.js (updated useState)
const ThemeToggle = () => {
const [theme, setTheme] = useState(() => {
// Check localStorage for a saved theme
const savedTheme = localStorage.getItem('theme');
// If there is a saved theme, use it. Otherwise, default to 'light'.
return savedTheme ? savedTheme : 'light';
});
// ... rest of the component is the same
And now... it works! Refresh the page. The theme stays put. You've just created a persistent user setting.
But wait. If you refresh, do you see a quick flash of the light theme before it switches to dark? This is a common little annoyance known as a FOUC (Flash of Unstyled Content), or in our case, a Flash of Incorrect Theme. It happens because our React app has to load, mount the component, and then run our useEffect to set the class. There's a tiny, but noticeable, delay.
To fix this properly, we need to run a tiny script before React even thinks about loading. We can add this directly to our public/index.html file in a <script> tag, right inside the <head>.
<!-- public/index.html -->
<head>
<!-- ... other head tags -->
<script>
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
</script>
<title>React App</title>
</head>
This tiny, vanilla JS snippet runs instantly. It checks localStorage and applies the dark class before the rest of the page even renders, completely eliminating that flash. You might notice I also slipped in a check for the user's system preference as a fallback—we'll touch on that next.
Advanced Dark Mode Techniques (Optional)
You've already built a production-ready dark mode toggle. Seriously, what we have is solid. But if you want to take it to the next level, here are a couple of advanced techniques to add even more polish.
Respecting System Preferences (prefers-color-scheme)
Wouldn't it be great if, for a first-time visitor, our site automatically matched their OS theme? We can totally do that! The browser gives us a way to check this with a media query: (prefers-color-scheme: dark).
We can enhance our theme loading logic to follow a smart hierarchy:
- Did the user make an explicit choice? Check
localStorage. - If not, what is their OS preference?
- If all else fails, default to
light.
Our inline script in index.html already does this! Let's just update our React component's initial state to match that logic, just to keep everything perfectly in sync.
// src/components/ThemeToggle.js (final useState)
const ThemeToggle = () => {
const [theme, setTheme] = useState(() => {
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
return localStorage.getItem('theme');
}
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
});
// ... rest of the component
Now our app is not just smart—it's considerate.
Smooth Transitions for React UI Tutorial
I mentioned this earlier, but it's so important it's worth repeating. Adding transition-colors and a duration-* class to your elements makes the theme change feel incredibly polished and professional.
Apply it to any element that changes color: backgrounds, text, borders, even SVGs.
<div class="bg-slate-100 dark:bg-slate-900 transition-colors duration-300">
<!-- Content -->
</div>
It’s such a tiny addition, but it makes a huge difference in the perceived quality of your site.
Troubleshooting Common React Dark Mode Issues
If you've run into a snag, don't worry, you're not alone. Here are a couple of the most common "gotchas" I've seen:
-
dark:classes aren't working at all!- 99% of the time, this is because you forgot to set
darkMode: 'class'in yourtailwind.config.jsfile. Double- and triple-check it! The other 1% of the time, yourcontentarray in the same file might not be pointing to your component files correctly.
- 99% of the time, this is because you forgot to set
-
I see a flash of the wrong theme on page load.
- This is that FOUC problem we talked about. The bulletproof solution is that small blocking script in the
<head>of yourpublic/index.html. Don't rely onuseEffectalone for setting the initial theme.
- This is that FOUC problem we talked about. The bulletproof solution is that small blocking script in the
-
My icons or other elements aren't changing color.
- Remember that every property needs its own
dark:variant. For SVGs, you might need to change thefillorstrokecolor. For text, it'stext-color. For borders,border-color. You have to be explicit for each property you want to change.
- Remember that every property needs its own
Conclusion: You've Mastered React Dark Theme with Tailwind CSS!
And there you have it. You didn't just copy and paste some code; you walked through the entire process, from setup and strategy to implementation and polish.
You've built a fully-featured, persistent, and user-friendly dark mode toggle in React, all powered by the wonderfully intuitive dark: variant from Tailwind CSS. You learned how to manage state with hooks, how to persist that state in the browser, and how to solve tricky real-world problems like the dreaded theme flash.
This is more than just a styling trick. It's a fundamental piece of modern UI/UX. The skills you've honed here—managing state, interacting with browser APIs, and thinking conditionally about styling—are things you'll use on every project.
So go ahead, add that toggle to your next app. Your users (and their eyes at 2 AM) will thank you for it.
Frequently Asked Questions
What's the main difference between Tailwind's
classandmediadark mode strategies? Themediastrategy is automatic and relies entirely on the user's operating system setting (prefers-color-scheme). Theclassstrategy is manual, giving you full control via JavaScript to toggle a.darkclass on the<html>element. For building a user-facing toggle button, theclassstrategy is the way to go.
Why do we need a script in
index.html? Can't we just useuseEffect? You can useuseEffect, but it runs after your React app has started rendering. This causes a noticeable "flash" where the default (usually light) theme appears for a split second before your code switches it to dark. The inline script in the<head>runs before anything is rendered, completely eliminating this flash for a seamless user experience.
Can I use this approach with other state management libraries like Redux or Zustand? Absolutely! The core logic remains the same: you need a piece of state to hold the current theme ('light' or 'dark') and a way to apply the
.darkclass to the<html>element. You can manage that state withuseState, Redux, Zustand, or any other state management library you prefer. TheuseEffecthook that interacts with the DOM would still work perfectly.