Back to Blog
Tailwind CSSDark ModeCSSUI

Implementing Dark Mode with Tailwind CSS

Build dark mode in Tailwind. From basic setup to system preferences to persistence patterns.

B
Bootspring Team
Engineering
August 16, 2021
6 min read

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.

Share this article

Help spread the word about Bootspring