The Starter Kit includes a complete dark mode implementation using next-themes and Tailwind CSS. Users can toggle between light, dark, and system themes with their preference persisted across sessions.
Overview
The theme system provides:
Light and dark mode with instant switching
System preference detection that respects OS settings
Persistent user selection stored in localStorage
No flash of unstyled content on page load
Tailwind integration using dark: class modifiers
Architecture
ThemeProvider Setup
The theme system is initialized in the root layout:
import { ThemeProvider } from 'next-themes'
export default function RootLayout ({ children } : { children : React . ReactNode }) {
return (
< html lang = "en" suppressHydrationWarning >
< body >
< DeploymentModeProvider deploymentConfig = { deploymentConfig } >
< AuthProvider user = { user } >
< ThemeProvider
attribute = "class"
defaultTheme = "system"
enableSystem
disableTransitionOnChange
>
{ children }
</ ThemeProvider >
</ AuthProvider >
</ DeploymentModeProvider >
</ body >
</ html >
)
}
How the theme is applied. Using "class" enables Tailwind’s dark: modifiers.
The default theme when no preference is stored. "system" follows the OS preference.
Allows detecting and using the system theme preference.
disableTransitionOnChange
Prevents CSS transitions during theme switches for instant changes. Set to true to avoid visual glitches.
The suppressHydrationWarning prop on the <html> element prevents hydration warnings caused by next-themes modifying the class attribute before React hydrates.
ThemeSwitcher Component
The header includes a theme toggle button:
components/theme-switcher.tsx
'use client'
import { Moon , Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect , useState } from 'react'
import { Button } from '@/components/ui/button'
export function ThemeSwitcher () {
const [ mounted , setMounted ] = useState ( false )
const { theme , setTheme } = useTheme ()
useEffect (() => {
setMounted ( true )
}, [])
if ( ! mounted ) {
return null
}
return (
< Button
variant = "ghost"
size = "icon"
onClick = { () => setTheme ( theme === 'light' ? 'dark' : 'light' ) }
>
< Sun className = "h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
< Moon className = "absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
< span className = "sr-only" > Toggle theme </ span >
</ Button >
)
}
The component returns null until mounted to prevent hydration mismatches, since the theme is determined client-side.
Tailwind Configuration
Enabling Dark Mode
Dark mode is enabled in tailwind.config.ts:
import type { Config } from "tailwindcss" ;
const config : Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}" ,
"./components/**/*.{js,ts,jsx,tsx,mdx}" ,
"./app/**/*.{js,ts,jsx,tsx,mdx}" ,
],
darkMode: "class" , // Enable class-based dark mode
theme: {
extend: {
// Your theme customizations
},
},
plugins: [],
};
export default config ;
Set to "class" to use next-themes with Tailwind’s dark: modifier. The alternative "media" uses CSS media queries only.
Dark Mode Color Palette
Define colors that work in both themes:
const config : Config = {
theme: {
extend: {
colors: {
// Semantic colors that adapt to theme
background: {
DEFAULT: '#ffffff' ,
dark: '#0a0a0a' ,
},
foreground: {
DEFAULT: '#0a0a0a' ,
dark: '#ffffff' ,
},
// Component-specific colors
card: {
DEFAULT: '#ffffff' ,
dark: '#171717' ,
},
'card-foreground' : {
DEFAULT: '#0a0a0a' ,
dark: '#fafafa' ,
},
border: {
DEFAULT: '#e5e5e5' ,
dark: '#262626' ,
},
// Brand colors with dark variants
primary: {
50 : '#f0fdf4' ,
100 : '#dcfce7' ,
200 : '#bbf7d0' ,
300 : '#86efac' ,
400 : '#4ade80' ,
500 : '#22c55e' ,
600 : '#16a34a' ,
700 : '#15803d' ,
800 : '#166534' ,
900 : '#14532d' ,
950 : '#052e16' ,
},
},
},
},
};
Styling Components for Dark Mode
Basic Dark Mode Styles
Use Tailwind’s dark: modifier to apply dark mode styles:
< div className = "bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100" >
< h1 className = "text-2xl font-bold text-gray-900 dark:text-white" >
Title adapts to theme
</ h1 >
< p className = "text-gray-600 dark:text-gray-400" >
Secondary text with good contrast in both modes
</ p >
</ div >
Component Example
Create theme-aware components:
import { cn } from '@/lib/utils'
interface CardProps {
children : React . ReactNode
className ?: string
}
export function Card ({ children , className } : CardProps ) {
return (
< div
className = { cn (
'rounded-lg border bg-white p-6 shadow-sm' ,
'dark:border-gray-800 dark:bg-gray-900' ,
className
) }
>
{ children }
</ div >
)
}
Complex Color Transitions
Handle colors that need different values in each theme:
< button className = "
bg-blue-500 hover:bg-blue-600
dark:bg-blue-600 dark:hover:bg-blue-700
text-white
border border-blue-600 dark:border-blue-500
" >
Themed Button
</ button >
CSS Variables Approach
For dynamic theming, use CSS variables:
Define Variables
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background : 0 0 % 100 % ;
--foreground : 0 0 % 4 % ;
--card : 0 0 % 100 % ;
--card-foreground : 0 0 % 4 % ;
--primary : 142 76 % 45 % ;
--primary-foreground : 0 0 % 100 % ;
--border : 0 0 % 90 % ;
--radius : 0.5 rem ;
}
.dark {
--background : 0 0 % 4 % ;
--foreground : 0 0 % 98 % ;
--card : 0 0 % 9 % ;
--card-foreground : 0 0 % 98 % ;
--primary : 142 70 % 50 % ;
--primary-foreground : 0 0 % 4 % ;
--border : 0 0 % 15 % ;
}
}
Use in Tailwind
Reference CSS variables in Tailwind config:
const config : Config = {
theme: {
extend: {
colors: {
background: 'hsl(var(--background))' ,
foreground: 'hsl(var(--foreground))' ,
card: {
DEFAULT: 'hsl(var(--card))' ,
foreground: 'hsl(var(--card-foreground))' ,
},
primary: {
DEFAULT: 'hsl(var(--primary))' ,
foreground: 'hsl(var(--primary-foreground))' ,
},
border: 'hsl(var(--border))' ,
},
borderRadius: {
lg: 'var(--radius)' ,
md: 'calc(var(--radius) - 2px)' ,
sm: 'calc(var(--radius) - 4px)' ,
},
},
},
};
Apply in Components
< div className = "bg-background text-foreground border-border" >
< div className = "bg-card text-card-foreground rounded-lg" >
Theme-aware card
</ div >
</ div >
Using HSL color values in CSS variables makes it easy to adjust saturation and lightness programmatically.
Advanced Theme Patterns
Multi-Theme Dropdown
Create a full theme selector with light, dark, and system options:
components/theme-selector.tsx
'use client'
import { Check , Moon , Sun , Monitor } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect , useState } from 'react'
import {
DropdownMenu ,
DropdownMenuContent ,
DropdownMenuItem ,
DropdownMenuTrigger ,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
export function ThemeSelector () {
const [ mounted , setMounted ] = useState ( false )
const { theme , setTheme } = useTheme ()
useEffect (() => {
setMounted ( true )
}, [])
if ( ! mounted ) {
return null
}
const themes = [
{ value: 'light' , label: 'Light' , icon: Sun },
{ value: 'dark' , label: 'Dark' , icon: Moon },
{ value: 'system' , label: 'System' , icon: Monitor },
]
return (
< DropdownMenu >
< DropdownMenuTrigger asChild >
< Button variant = "ghost" size = "icon" >
< Sun className = "h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
< Moon className = "absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
< span className = "sr-only" > Toggle theme </ span >
</ Button >
</ DropdownMenuTrigger >
< DropdownMenuContent align = "end" >
{ themes . map (( t ) => (
< DropdownMenuItem
key = { t . value }
onClick = { () => setTheme ( t . value ) }
className = "cursor-pointer"
>
< t.icon className = "mr-2 h-4 w-4" />
< span > { t . label } </ span >
{ theme === t . value && < Check className = "ml-auto h-4 w-4" /> }
</ DropdownMenuItem >
)) }
</ DropdownMenuContent >
</ DropdownMenu >
)
}
Detecting System Theme
Access the resolved theme (accounting for system preference):
'use client'
import { useTheme } from 'next-themes'
export function ThemedComponent () {
const { theme , resolvedTheme } = useTheme ()
// theme: 'light' | 'dark' | 'system'
// resolvedTheme: 'light' | 'dark' (actual computed theme)
return (
< div >
Selected: { theme }
Active: { resolvedTheme }
</ div >
)
}
Conditional Rendering
Render different content based on theme:
import { useTheme } from 'next-themes'
export function ThemedImage () {
const { resolvedTheme } = useTheme ()
return (
< img
src = { resolvedTheme === 'dark' ? '/logo-dark.svg' : '/logo-light.svg' }
alt = "Logo"
/>
)
}
Custom Color Schemes
Creating Theme Variants
Define multiple color schemes:
:root {
--theme-blue-primary : 219 95 % 55 % ;
--theme-green-primary : 142 76 % 45 % ;
--theme-purple-primary : 271 81 % 56 % ;
}
[ data-color-scheme = "blue" ] {
--primary : var ( --theme-blue-primary );
}
[ data-color-scheme = "green" ] {
--primary : var ( --theme-green-primary );
}
[ data-color-scheme = "purple" ] {
--primary : var ( --theme-purple-primary );
}
Implement color scheme switching:
'use client'
import { useEffect , useState } from 'react'
export function ColorSchemeSelector () {
const [ scheme , setScheme ] = useState ( 'blue' )
useEffect (() => {
document . documentElement . setAttribute ( 'data-color-scheme' , scheme )
}, [ scheme ])
return (
< select value = { scheme } onChange = { ( e ) => setScheme ( e . target . value ) } >
< option value = "blue" > Blue </ option >
< option value = "green" > Green </ option >
< option value = "purple" > Purple </ option >
</ select >
)
}
Best Practices
Always Use Semantic Colors
Use semantic color names like background, foreground, border instead of specific colors like white or gray-900. This makes theme changes easier. // Good
< div className = "bg-background text-foreground" >
// Avoid
< div className = "bg-white dark:bg-gray-900 text-black dark:text-white" >
Always test your UI in both light and dark modes. Colors that work in light mode may have insufficient contrast in dark mode.
Provide Sufficient Contrast
Ensure WCAG AA contrast ratios (4.5:1 for text) in both themes. Use tools like Contrast Checker .
Avoid Transition Flickering
Set disableTransitionOnChange to prevent CSS transitions from animating during theme switches, which can cause visual glitches.
Handle Images Appropriately
For logos and images, provide theme-specific variants or use images that work in both themes. Transparent PNGs and SVGs work best.
Preserve Theme Across Navigation
next-themes automatically persists the user’s theme preference in localStorage. No additional code needed.
Accessibility Considerations
Respecting System Preferences
The default system theme respects the user’s OS-level preference:
< ThemeProvider defaultTheme = "system" enableSystem >
This ensures users who have set a dark mode preference at the OS level see dark mode by default.
Reduced Motion
Respect the prefers-reduced-motion setting:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration : 0.01 ms !important ;
animation-iteration-count : 1 !important ;
transition-duration : 0.01 ms !important ;
}
}
Focus Indicators
Ensure focus indicators are visible in both themes:
< button className = "
focus:outline-none
focus:ring-2 focus:ring-primary-500 focus:ring-offset-2
dark:focus:ring-primary-400 dark:focus:ring-offset-gray-900
" >
Button with themed focus
</ button >
Troubleshooting
Flash of Unstyled Content
If you see a flash when the page loads, ensure suppressHydrationWarning is on the <html> element: < html lang = "en" suppressHydrationWarning >
Ensure next-themes can access localStorage. Check browser settings and privacy modes. The theme is stored in localStorage.theme.
If theme toggle icons don’t switch properly, verify both icons are present and CSS transitions are working: < Sun className = "... dark:scale-0" />
< Moon className = "... dark:scale-100" />
If you see hydration errors, ensure theme-dependent components use the mounted check: const [ mounted , setMounted ] = useState ( false )
useEffect (() => setMounted ( true ), [])
if ( ! mounted ) return null
Next Steps