How to Build a Theme Toggle in React + Tailwind (Dark Mode)
Learn how to build a polished theme toggle in React and Tailwind CSS to add seamless dark mode switching to any modern web application.
Why a Theme Toggle Matters in 2026
Dark mode is no longer a novelty — it is a baseline expectation for modern web applications. Studies consistently show that over 80% of users prefer dark interfaces for night-time use, and operating systems now ship with automatic light/dark switching built in. A well-implemented theme toggle respects the user's preference, persists it across sessions, and transitions smoothly without a flash of unstyled content.
Beyond user comfort, a theme toggle directly impacts perceived quality. Apps that support dark mode feel more polished and professional, which builds trust. If you are building with Empire UI components — whether glassmorphic cards, neobrutalist buttons, or animated backgrounds — your design system needs to handle both colour schemes gracefully.
This guide walks you through the complete implementation: reading the system preference, storing the user's choice in localStorage, toggling the Tailwind dark class on the <html> element, and wiring up a clean animated toggle button — all without any external state management library.
Setting Up Tailwind CSS for Dark Mode
Tailwind's dark mode support is controlled by the darkMode key in tailwind.config.js. The two main strategies are 'media' (follows the OS preference automatically) and 'class' (lets you control dark mode manually by adding a class to a parent element). For a user-controllable theme toggle, you always want 'class' mode.
Open your tailwind.config.js and set the following:
``js
// tailwind.config.js
module.exports = {
darkMode: 'class',
content: ['./src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};
`
With this configuration, Tailwind will apply dark: variants whenever the class dark is present on the <html> element. Every utility like dark:bg-gray-900 or dark:text-white` will activate accordingly.
Make sure your root index.html (or _document.tsx in Next.js) does not hard-code a dark class — you will add and remove it dynamically via JavaScript so the user's saved preference is applied before the first paint, avoiding any flash.
Building the useTheme Hook
The cleanest architecture is a custom React hook that owns all the theme logic: reading localStorage, falling back to the OS preference via window.matchMedia, and exposing a toggleTheme function to any component that needs it.
Create a new file at src/hooks/useTheme.ts:
``ts
import { useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'light';
const stored = localStorage.getItem('theme') as Theme | null;
if (stored) return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
});
useEffect(() => {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () =>
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
return { theme, toggleTheme };
}
`
The lazy initialiser in useState runs only once on mount, so you get the correct theme **synchronously** before the first render. The useEffect then keeps the DOM class and localStorage in sync whenever theme` changes.
This hook is framework-agnostic within the React ecosystem — it works identically in Vite, Create React App, and Next.js (with a minor adjustment for SSR described in the next section). You can also pair it with a custom cursor component from Empire UI to make the dark-mode experience feel even more premium.
Creating the Animated Toggle Button
A bare <button> that swaps text is functional but forgettable. The toggle button is a micro-interaction opportunity — a small, satisfying animation here signals quality throughout the entire product. The example below uses Tailwind transitions and a sun/moon SVG swap.
``tsx
// src/components/ThemeToggle.tsx
import { useTheme } from '../hooks/useTheme';
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
const isDark = theme === 'dark';
return (
<button
onClick={toggleTheme}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
className="
relative flex items-center justify-center
w-12 h-6 rounded-full
bg-gray-200 dark:bg-gray-700
transition-colors duration-300
focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
focus-visible:ring-blue-500
"
>
<span
className={
absolute left-1 w-4 h-4 rounded-full
bg-white dark:bg-gray-900
shadow-md transform transition-transform duration-300
${isDark ? 'translate-x-6' : 'translate-x-0'}
}
/>
<span className="sr-only">
{isDark ? 'Dark mode on' : 'Light mode on'}
</span>
</button>
);
}
`
The pill track changes colour via bg-gray-200 dark:bg-gray-700 and the inner knob slides with translate-x-6` when dark mode is active. No JavaScript animation libraries required — pure Tailwind transitions.
Drop <ThemeToggle /> into your site header, nav bar, or settings panel. If you are using Empire UI's pre-built layouts from /templates, the toggle slots naturally into the top-right utility area. You can also explore /glassmorphism card components whose backdrop-filter and transparency look stunning in both light and dark contexts.
Handling SSR and the Flash of Wrong Theme
Server-side rendering frameworks like Next.js render HTML on the server before the browser runs any JavaScript, which means localStorage is not available during that phase. If you naively rely on useEffect to apply the theme class, users on slow connections may briefly see the wrong colour scheme — the dreaded flash of unstyled content (FOUC).
The standard fix is to inject a tiny blocking <script> tag into the <head> that reads localStorage and applies the dark class before the browser paints a single pixel:
``html
<!-- In _document.tsx (Next.js Pages Router) or app/layout.tsx (App Router) -->
<script
dangerouslySetInnerHTML={{
__html:
(function() {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark' || (!stored && prefersDark)) {
document.documentElement.classList.add('dark');
}
})();
,
}}
/>
`
Because this script runs **synchronously** before any CSS or HTML is painted, it eliminates the flash entirely. The useTheme` hook then picks up from where the script left off — both agree on the initial theme state.
For Vite/CRA apps served statically, you can add an equivalent <script> block directly in public/index.html. This pattern is the industry standard and is used by virtually every professional dark-mode implementation. Explore more advanced animation patterns in our blog for ideas on extending this further.
Extending the Toggle Across Your Component Library
Once the toggle infrastructure is in place, the real work is auditing every component to ensure it responds correctly to the dark class. A systematic approach: open your Tailwind components one by one and add dark: variants for backgrounds, borders, text, shadows, and any hardcoded colours. Empire UI components at /tools already ship with full dark-mode variants baked in, so you can drop them straight into a dark-ready layout.
For advanced visual styles like glassmorphism, dark mode requires extra care. A light glass card uses bg-white/20 backdrop-blur-md border border-white/30, while its dark counterpart should shift to dark:bg-gray-800/40 dark:border-gray-700/40 to keep the frosted effect legible against a dark background. The contrast ratios are different, so always check with a contrast checker or the accessibility tools available on Empire UI.
If your app grows to include multiple themes beyond just light and dark — think brand palettes or the 40 style presets available through Empire UI's MCP server — you can extend the useTheme hook to accept any string value and map it to a CSS class. The core pattern remains the same: one source of truth in a hook, one class on `<html>`, persistence in `localStorage`. That simplicity is what makes this architecture scale.
FAQ
The 'media' strategy automatically applies dark styles based on the user's operating system preference using the prefers-color-scheme media query, with no way for the user to override it in-app. The 'class' strategy gives you full control by activating dark styles only when a dark class is present on the <html> element, which is required for a manual theme toggle.
Store the chosen theme in localStorage whenever it changes — a single localStorage.setItem('theme', theme) call inside a useEffect is all you need. On the next page load, read it back in the lazy initialiser of useState before the first render so the correct theme is applied immediately.
Add a tiny inline <script> tag in the document <head> that runs synchronously before any paint. It reads localStorage and adds the dark class to document.documentElement if needed. Because it blocks rendering, the browser never shows the wrong theme even for a fraction of a second.
Absolutely. The hook and DOM-class approach works identically with custom CSS variables. Define your variables under :root for light mode and under .dark for dark mode, then let the useTheme hook toggle the dark class on <html> as normal. Your CSS variables will cascade automatically.
Yes — all Empire UI components are built with Tailwind's dark: variant pattern and are designed to work correctly as soon as you add the dark class to your document root. Browse the full component library at empire-ui.com to find ready-made dark-mode-ready cards, navbars, backgrounds, and more.