Dark mode improves user experience. Here's how to implement it with Tailwind CSS.
Basic Setup#
1// tailwind.config.js
2module.exports = {
3 darkMode: 'class', // or 'media' for system preference only
4 theme: {
5 extend: {},
6 },
7};1<!-- Using dark: prefix -->
2<div class="bg-white dark:bg-gray-900">
3 <h1 class="text-gray-900 dark:text-white">
4 Hello World
5 </h1>
6 <p class="text-gray-600 dark:text-gray-300">
7 This adapts to dark mode
8 </p>
9</div>
10
11<!-- Toggle dark mode -->
12<html class="dark">
13 <!-- Content uses dark mode -->
14</html>Theme Toggle Component#
1// ThemeToggle.tsx
2import { useState, useEffect } from 'react';
3
4type Theme = 'light' | 'dark' | 'system';
5
6export function ThemeToggle() {
7 const [theme, setTheme] = useState<Theme>('system');
8
9 useEffect(() => {
10 // Get initial theme from localStorage
11 const stored = localStorage.getItem('theme') as Theme | null;
12 if (stored) {
13 setTheme(stored);
14 }
15 }, []);
16
17 useEffect(() => {
18 const root = document.documentElement;
19
20 if (theme === 'system') {
21 localStorage.removeItem('theme');
22 const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
23 root.classList.toggle('dark', systemDark);
24 } else {
25 localStorage.setItem('theme', theme);
26 root.classList.toggle('dark', theme === 'dark');
27 }
28 }, [theme]);
29
30 // Listen for system preference changes
31 useEffect(() => {
32 if (theme !== 'system') return;
33
34 const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
35
36 const handleChange = (e: MediaQueryListEvent) => {
37 document.documentElement.classList.toggle('dark', e.matches);
38 };
39
40 mediaQuery.addEventListener('change', handleChange);
41 return () => mediaQuery.removeEventListener('change', handleChange);
42 }, [theme]);
43
44 return (
45 <div className="flex gap-2">
46 <button
47 onClick={() => setTheme('light')}
48 className={`p-2 rounded ${theme === 'light' ? 'bg-blue-500 text-white' : ''}`}
49 >
50 ☀️ Light
51 </button>
52 <button
53 onClick={() => setTheme('dark')}
54 className={`p-2 rounded ${theme === 'dark' ? 'bg-blue-500 text-white' : ''}`}
55 >
56 🌙 Dark
57 </button>
58 <button
59 onClick={() => setTheme('system')}
60 className={`p-2 rounded ${theme === 'system' ? 'bg-blue-500 text-white' : ''}`}
61 >
62 💻 System
63 </button>
64 </div>
65 );
66}Prevent Flash of Wrong Theme#
1<!-- Add to <head> before any stylesheets -->
2<script>
3 // Immediately set theme to prevent flash
4 (function() {
5 const theme = localStorage.getItem('theme');
6 const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
7
8 if (theme === 'dark' || (!theme && systemDark)) {
9 document.documentElement.classList.add('dark');
10 }
11 })();
12</script>1// Next.js: pages/_document.tsx
2import { Html, Head, Main, NextScript } from 'next/document';
3
4export default function Document() {
5 return (
6 <Html>
7 <Head />
8 <body>
9 <script
10 dangerouslySetInnerHTML={{
11 __html: `
12 (function() {
13 const theme = localStorage.getItem('theme');
14 const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
15 if (theme === 'dark' || (!theme && systemDark)) {
16 document.documentElement.classList.add('dark');
17 }
18 })();
19 `,
20 }}
21 />
22 <Main />
23 <NextScript />
24 </body>
25 </Html>
26 );
27}Custom Theme Colors#
1// tailwind.config.js
2module.exports = {
3 darkMode: 'class',
4 theme: {
5 extend: {
6 colors: {
7 // Semantic color tokens
8 background: {
9 DEFAULT: '#ffffff',
10 dark: '#0f172a',
11 },
12 foreground: {
13 DEFAULT: '#0f172a',
14 dark: '#f8fafc',
15 },
16 primary: {
17 DEFAULT: '#3b82f6',
18 dark: '#60a5fa',
19 },
20 muted: {
21 DEFAULT: '#64748b',
22 dark: '#94a3b8',
23 },
24 },
25 },
26 },
27};1/* Using CSS variables for themes */
2@tailwind base;
3@tailwind components;
4@tailwind utilities;
5
6@layer base {
7 :root {
8 --color-background: 255 255 255;
9 --color-foreground: 15 23 42;
10 --color-primary: 59 130 246;
11 --color-muted: 100 116 139;
12 }
13
14 .dark {
15 --color-background: 15 23 42;
16 --color-foreground: 248 250 252;
17 --color-primary: 96 165 250;
18 --color-muted: 148 163 184;
19 }
20}1// tailwind.config.js with CSS variables
2module.exports = {
3 theme: {
4 extend: {
5 colors: {
6 background: 'rgb(var(--color-background) / <alpha-value>)',
7 foreground: 'rgb(var(--color-foreground) / <alpha-value>)',
8 primary: 'rgb(var(--color-primary) / <alpha-value>)',
9 muted: 'rgb(var(--color-muted) / <alpha-value>)',
10 },
11 },
12 },
13};1<!-- Usage -->
2<div class="bg-background text-foreground">
3 <button class="bg-primary text-white">
4 Primary Button
5 </button>
6 <p class="text-muted">
7 Muted text
8 </p>
9</div>Component Patterns#
1// Card with dark mode
2function Card({ children, title }) {
3 return (
4 <div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg dark:shadow-gray-900/30 p-6">
5 <h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
6 {title}
7 </h2>
8 <div className="text-gray-600 dark:text-gray-300">
9 {children}
10 </div>
11 </div>
12 );
13}
14
15// Button variants
16function Button({ variant = 'primary', children }) {
17 const variants = {
18 primary: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white',
19 secondary: 'bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white',
20 outline: 'border-2 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-900 dark:text-white',
21 };
22
23 return (
24 <button className={`px-4 py-2 rounded-lg transition-colors ${variants[variant]}`}>
25 {children}
26 </button>
27 );
28}
29
30// Input with dark mode
31function Input({ label, ...props }) {
32 return (
33 <label className="block">
34 <span className="text-gray-700 dark:text-gray-300">{label}</span>
35 <input
36 className="mt-1 block w-full rounded-md
37 bg-white dark:bg-gray-800
38 border-gray-300 dark:border-gray-600
39 text-gray-900 dark:text-white
40 placeholder-gray-400 dark:placeholder-gray-500
41 focus:border-blue-500 dark:focus:border-blue-400
42 focus:ring-blue-500 dark:focus:ring-blue-400"
43 {...props}
44 />
45 </label>
46 );
47}Images and Media#
1// Different images for light/dark
2function Logo() {
3 return (
4 <>
5 <img
6 src="/logo-light.svg"
7 alt="Logo"
8 className="block dark:hidden"
9 />
10 <img
11 src="/logo-dark.svg"
12 alt="Logo"
13 className="hidden dark:block"
14 />
15 </>
16 );
17}
18
19// Invert images
20function Icon() {
21 return (
22 <img
23 src="/icon.svg"
24 className="dark:invert"
25 alt="Icon"
26 />
27 );
28}
29
30// Adjust image brightness
31function HeroImage() {
32 return (
33 <img
34 src="/hero.jpg"
35 className="dark:brightness-90"
36 alt="Hero"
37 />
38 );
39}React Context Provider#
1// ThemeContext.tsx
2import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
3
4type Theme = 'light' | 'dark' | 'system';
5
6interface ThemeContextType {
7 theme: Theme;
8 setTheme: (theme: Theme) => void;
9 resolvedTheme: 'light' | 'dark';
10}
11
12const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
13
14export function ThemeProvider({ children }: { children: ReactNode }) {
15 const [theme, setTheme] = useState<Theme>('system');
16 const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
17
18 useEffect(() => {
19 const stored = localStorage.getItem('theme') as Theme | null;
20 if (stored) {
21 setTheme(stored);
22 }
23 }, []);
24
25 useEffect(() => {
26 const root = document.documentElement;
27 let resolved: 'light' | 'dark';
28
29 if (theme === 'system') {
30 resolved = window.matchMedia('(prefers-color-scheme: dark)').matches
31 ? 'dark'
32 : 'light';
33 localStorage.removeItem('theme');
34 } else {
35 resolved = theme;
36 localStorage.setItem('theme', theme);
37 }
38
39 root.classList.remove('light', 'dark');
40 root.classList.add(resolved);
41 setResolvedTheme(resolved);
42 }, [theme]);
43
44 useEffect(() => {
45 if (theme !== 'system') return;
46
47 const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
48
49 const handleChange = (e: MediaQueryListEvent) => {
50 const resolved = e.matches ? 'dark' : 'light';
51 document.documentElement.classList.remove('light', 'dark');
52 document.documentElement.classList.add(resolved);
53 setResolvedTheme(resolved);
54 };
55
56 mediaQuery.addEventListener('change', handleChange);
57 return () => mediaQuery.removeEventListener('change', handleChange);
58 }, [theme]);
59
60 return (
61 <ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
62 {children}
63 </ThemeContext.Provider>
64 );
65}
66
67export function useTheme() {
68 const context = useContext(ThemeContext);
69 if (!context) {
70 throw new Error('useTheme must be used within ThemeProvider');
71 }
72 return context;
73}Animations#
1// Smooth transition
2<div className="transition-colors duration-200 bg-white dark:bg-gray-900">
3 Content
4</div>
5
6// Only transition specific properties
7<div className="transition-[background-color,color] duration-200">
8 Content
9</div>
10
11// Disable transitions during theme change
12// Add 'no-transitions' class to html during toggle
13useEffect(() => {
14 document.documentElement.classList.add('no-transitions');
15 // Apply theme...
16 setTimeout(() => {
17 document.documentElement.classList.remove('no-transitions');
18 }, 0);
19}, [theme]);
20
21// CSS
22.no-transitions * {
23 transition: none !important;
24}Best Practices#
Colors:
✓ Use semantic color names
✓ Maintain sufficient contrast
✓ Test both themes thoroughly
✓ Consider color blind users
Implementation:
✓ Prevent flash of wrong theme
✓ Respect system preference
✓ Allow manual override
✓ Persist user preference
Performance:
✓ Use CSS variables for dynamic colors
✓ Avoid unnecessary re-renders
✓ Minimize transition duration
✓ Test on slower devices
Conclusion#
Dark mode with Tailwind is straightforward using the dark: prefix. Use CSS variables for theme colors, prevent flash with inline scripts, and provide system preference detection with manual override. Always test both themes for contrast and readability.