Dark mode has become a standard feature in modern web applications. Users expect the ability to switch between light and dark themes, and many prefer their interfaces to respect their system preferences. This feature is essential for modern portfolio websites and blog platforms. In this guide, I'll show you how to implement a comprehensive dark mode system using Tailwind CSS and React.
Update, September 2026: this guide targets Tailwind CSS v3. If you are on Tailwind v4, the
tailwind.config.jssetup below will not work — v4 ignores that file unless you explicitly load it, sodarkMode: 'class'silently never applies and everydark:utility compiles down toprefers-color-schemeinstead. Your toggle will appear to do nothing while the OS setting quietly wins. I shipped exactly that bug on this site for over a year without noticing. See the Tailwind v4 section below for the one-line fix, and the redesign post for how it surfaced. The React context, toggle, and FOUC-prevention parts of this guide are unchanged and still apply to both versions.
Why Dark Mode Matters
Dark mode isn't just a trendy feature—it offers real benefits for modern portfolio websites and web applications:
- Reduced Eye Strain: Especially important for users who spend long hours on screens
- Battery Life: Can extend battery life on OLED displays
- User Preference: Many users simply prefer dark interfaces
- Professional Appeal: Shows attention to modern UX trends
Setting Up Tailwind CSS for Dark Mode
Tailwind v3 only. On v4, skip this section and use the CSS-based setup that follows it.
First, ensure your Tailwind configuration supports dark mode:
// tailwind.config.js
module.exports = {
darkMode: 'class', // Enable class-based dark mode
content: [
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
// Custom dark mode colors
dark: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
}
}
},
},
plugins: [],
}
Tailwind v4: Configure Dark Mode in CSS, Not JS
Tailwind v4 moved configuration out of JavaScript and into your CSS file. There is no automatic tailwind.config.js lookup, so the config above is inert unless you opt back into it with @config "./tailwind.config.js";.
This is the part that bites: the failure is silent. Tailwind doesn't warn you that your config was ignored. darkMode: 'class' never registers, so dark: falls back to its default meaning — the prefers-color-scheme media query. Everything still compiles. The site still looks fine on your machine, assuming your OS is set to light. Meanwhile every visitor with a dark OS theme gets dark styles you never chose, your toggle does nothing, and any half-finished light section renders white text on a white background.
In v4, declare the variant in CSS instead:
/* src/app/globals.css */
@import "tailwindcss";
/* Drive dark: from a .dark class on <html> instead of the OS setting */
@custom-variant dark (&:where(.dark, .dark *));
That one line replaces darkMode: 'class'. Every dark: utility in your markup now responds to the class your toggle sets, and the rest of this guide's React code works as written.
Define custom colors in CSS too, using @theme rather than theme.extend.colors:
@theme {
--color-dark-50: #f8fafc;
--color-dark-100: #f1f5f9;
--color-dark-800: #1e293b;
--color-dark-900: #0f172a;
}
A quick way to confirm the variant is live: set your OS to dark, then force the site to light with the toggle. If the page follows your toggle, the class variant is working. If it follows the OS and ignores the toggle, your config is still being skipped.
One more trap. If you're migrating from v3 and dark mode looked broken, you may have patched around it with hand-written overrides like .dark .card { background: #111 !important; }. Those keep working and hide the real problem, but they only cover the handful of elements you remembered. Delete them once the variant is correct — on this site that removed several hundred lines of CSS and fixed far more elements than the overrides ever touched.
Creating the Theme Context
Build a React context to manage theme state across your application:
// src/contexts/ThemeContext.tsx
'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('system');
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
const [mounted, setMounted] = useState(false);
// Handle theme resolution and application
useEffect(() => {
const root = window.document.documentElement;
// Remove existing theme classes
root.classList.remove('light', 'dark');
let newResolvedTheme: 'light' | 'dark';
if (theme === 'system') {
// Detect system preference
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
newResolvedTheme = systemTheme;
} else {
newResolvedTheme = theme;
}
setResolvedTheme(newResolvedTheme);
root.classList.add(newResolvedTheme);
// Store preference in localStorage
localStorage.setItem('theme-preference', theme);
}, [theme]);
// Listen for system preference changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
if (theme === 'system') {
const newResolvedTheme = e.matches ? 'dark' : 'light';
setResolvedTheme(newResolvedTheme);
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(newResolvedTheme);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
// Initialize theme from localStorage
useEffect(() => {
const stored = localStorage.getItem('theme-preference') as Theme;
if (stored && ['light', 'dark', 'system'].includes(stored)) {
setTheme(stored);
}
setMounted(true);
}, []);
// Prevent hydration mismatch
if (!mounted) {
return <div style={{ visibility: 'hidden' }}>{children}</div>;
}
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
Building the Theme Toggle Component
Create a user-friendly toggle component with multiple theme options:
// src/components/ThemeToggle.tsx
'use client';
import { useState } from 'react';
import { useTheme } from '@/contexts/ThemeContext';
interface ThemeToggleProps {
isScrolled?: boolean;
}
export default function ThemeToggle({ isScrolled = false }: ThemeToggleProps) {
const { theme, setTheme, resolvedTheme } = useTheme();
const [isOpen, setIsOpen] = useState(false);
const themes = [
{
value: 'light' as const,
label: 'Light',
icon: '☀️',
description: 'Light theme'
},
{
value: 'dark' as const,
label: 'Dark',
icon: '🌙',
description: 'Dark theme'
},
{
value: 'system' as const,
label: 'System',
icon: '💻',
description: 'Follow system preference'
},
];
const currentTheme = themes.find(t => t.value === theme);
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className={`p-2 rounded-lg transition-all duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 ${
isScrolled
? 'text-gray-700 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800'
: 'text-white hover:bg-white/10'
}`}
aria-label="Toggle theme"
aria-expanded={isOpen}
>
<span className="text-lg">{currentTheme?.icon}</span>
</button>
{isOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
{/* Dropdown */}
<div className="absolute right-0 top-full mt-2 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 min-w-[180px] overflow-hidden">
{themes.map((themeOption) => (
<button
key={themeOption.value}
onClick={() => {
setTheme(themeOption.value);
setIsOpen(false);
}}
className={`w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-200 flex items-center space-x-3 ${
theme === themeOption.value ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300'
}`}
>
<span className="text-lg">{themeOption.icon}</span>
<div>
<div className="font-medium">{themeOption.label}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{themeOption.description}
</div>
</div>
{theme === themeOption.value && (
<span className="ml-auto text-blue-500">✓</span>
)}
</button>
))}
</div>
</>
)}
</div>
);
}
Styling Components for Dark Mode
Use Tailwind's dark mode classes throughout your components:
// Example: Hero section with dark mode support
export default function Hero() {
return (
<section className="min-h-screen bg-gradient-to-br from-blue-600 via-purple-600 to-blue-800 dark:from-gray-900 dark:via-blue-900 dark:to-gray-800 flex items-center justify-center relative overflow-hidden">
<div className="container mx-auto px-6 text-center relative z-10">
<h1 className="text-5xl md:text-7xl font-bold text-white dark:text-gray-100 mb-6 leading-tight">
Full-Stack Developer
</h1>
<p className="text-xl md:text-2xl text-blue-100 dark:text-gray-300 mb-8 max-w-3xl mx-auto leading-relaxed">
Building modern web applications with cutting-edge technologies
</p>
<button className="bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 px-8 py-4 rounded-full font-semibold hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-300">
View My Work
</button>
</div>
</section>
);
}
Advanced Dark Mode Patterns
1. Image Handling for Dark Mode
Handle images that need different versions for light and dark modes:
interface ResponsiveImageProps {
lightSrc: string;
darkSrc: string;
alt: string;
className?: string;
}
export function ResponsiveImage({ lightSrc, darkSrc, alt, className }: ResponsiveImageProps) {
const { resolvedTheme } = useTheme();
return (
<img
src={resolvedTheme === 'dark' ? darkSrc : lightSrc}
alt={alt}
className={className}
/>
);
}
2. Chart and Data Visualization
Adapt chart colors for dark mode. This becomes especially important in serverless React applications where data visualization needs to work across different rendering contexts:
const getChartTheme = (isDark: boolean) => ({
backgroundColor: isDark ? '#1e293b' : '#ffffff',
textColor: isDark ? '#e2e8f0' : '#334155',
gridColor: isDark ? '#475569' : '#e2e8f0',
seriesColors: isDark
? ['#3b82f6', '#8b5cf6', '#06b6d4']
: ['#2563eb', '#7c3aed', '#0891b2']
});
3. CSS Custom Properties
Use CSS custom properties for dynamic theming:
/* globals.css */
:root {
--color-primary: 59 130 246; /* blue-500 */
--color-background: 255 255 255;
--color-text: 15 23 42; /* slate-900 */
}
.dark {
--color-primary: 96 165 250; /* blue-400 */
--color-background: 15 23 42; /* slate-900 */
--color-text: 248 250 252; /* slate-50 */
}
.custom-button {
background-color: rgb(var(--color-primary));
color: rgb(var(--color-background));
}
Performance Considerations
1. Preventing Flash of Incorrect Theme
Add a script to prevent theme flash on page load:
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
const theme = localStorage.getItem('theme-preference') || 'system';
if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
`,
}}
/>
</head>
<body className="transition-colors duration-300">
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}
2. Optimizing Theme Transitions
Add smooth transitions for theme changes:
/* globals.css */
* {
transition-property: background-color, border-color, color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
/* Disable transitions during theme change to prevent flicker */
.theme-changing * {
transition: none !important;
}
Testing Dark Mode
1. Manual Testing Checklist
- ✅ Toggle between all three theme modes
- ✅ Refresh page and verify theme persistence
- ✅ Test system preference changes
- ✅ Check all components in both themes
- ✅ Verify contrast ratios meet accessibility standards
2. Automated Testing
// __tests__/theme.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '@/contexts/ThemeContext';
import ThemeToggle from '@/components/ThemeToggle';
test('theme toggle switches between themes', () => {
render(
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>
);
const toggleButton = screen.getByLabelText('Toggle theme');
fireEvent.click(toggleButton);
expect(screen.getByText('Light')).toBeInTheDocument();
expect(screen.getByText('Dark')).toBeInTheDocument();
expect(screen.getByText('System')).toBeInTheDocument();
});
Accessibility Considerations
Ensure your dark mode implementation is accessible:
- Proper Contrast: Test color combinations with tools like WebAIM
- Reduced Motion: Respect user preferences for reduced motion
- Focus Indicators: Ensure focus states are visible in both themes
- Screen Readers: Use proper ARIA labels for theme controls
@media (prefers-reduced-motion: reduce) {
* {
transition-duration: 0.01ms !important;
}
}
Conclusion
Implementing dark mode with Tailwind CSS and React provides users with a modern, accessible experience while demonstrating attention to UX details. Whether you're building a Next.js blog or a complex application, dark mode is essential. Key takeaways:
- System Integration: Always respect user system preferences
- Smooth Transitions: Implement proper transitions and prevent theme flash
- Comprehensive Coverage: Apply dark mode styling to all components, including contact forms
- Performance: Optimize for fast theme switching
- Accessibility: Maintain proper contrast and accessibility standards
Dark mode has evolved from a nice-to-have feature to an expected standard. By implementing it thoughtfully with proper state management and smooth transitions, you create a professional, user-friendly experience that sets your application apart.
Looking for more web development tips? Check out my other articles on modern React patterns and performance optimization.

Written by
Richard Joseph Porter
Senior Laravel Developer with 14+ years of experience building scalable web applications. Specializing in PHP, Laravel, Vue.js, and AWS cloud infrastructure. Based in Cebu, Philippines, I help businesses modernize legacy systems and build high-performance APIs.
Get in touchLooking for expert web development?
With 14+ years of experience in Laravel, AWS, and modern web technologies, I help businesses build and scale their applications.
Related articles
13 min read
Taste Skill for Claude Code: A Backend Dev's Site Redesign
I can't design, so I let the taste skill for Claude Code audit my site. It fixed the look and found a dark mode bug I had shipped to visitors for over a year.
- claude-code
- ai-development
- tailwind
10 min read
React Server Components on Serverless: A Complete Guide
React Server Components on serverless architecture: 60% faster cold starts, 40% lower cost, and better Core Web Vitals - with the tradeoffs.
- react
- serverless
- nextjs
6 min read
Build a Modern Portfolio with Next.js 15
Build a stunning Next.js 15 portfolio with TypeScript, Tailwind CSS, dark mode, and animations. Achieve 98+ Lighthouse scores—complete guide inside.
- nextjs
- typescript
- tailwind