This guide provides a complete implementation of dark mode for Vite-based React applications using a custom React Context provider. This approach gives you full control over theme management without external dependencies.
Prerequisites
Before starting, ensure you have:
A Vite React application
EoN UI components installed
Tailwind CSS configured with dark mode enabled
Basic understanding of React Context API and hooks
This implementation uses React Context and localStorage to manage theme state, providing a lightweight solution without external dependencies.
Implementation Steps
Create Custom Theme Provider
Build a custom theme provider using React Context that handles theme state, localStorage persistence, and system preference detection. components/theme-provider.tsx
import { createContext , useContext , useEffect , useState } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children : React . ReactNode
defaultTheme ?: Theme
storageKey ?: string
}
type ThemeProviderState = {
theme : Theme
setTheme : ( theme : Theme ) => void
}
const initialState : ThemeProviderState = {
theme: "system" ,
setTheme : () => null ,
}
const ThemeProviderContext = createContext < ThemeProviderState >( initialState )
export function ThemeProvider ({
children ,
defaultTheme = "system" ,
storageKey = "vite-ui-theme" ,
... props
} : ThemeProviderProps ) {
const [ theme , setTheme ] = useState < Theme >(
() => ( localStorage . getItem ( storageKey ) as Theme ) || defaultTheme
)
useEffect (() => {
const root = window . document . documentElement
root . classList . remove ( "light" , "dark" )
if ( theme === "system" ) {
const systemTheme = window . matchMedia ( "(prefers-color-scheme: dark)" )
. matches
? "dark"
: "light"
root . classList . add ( systemTheme )
return
}
root . classList . add ( theme )
}, [ theme ])
const value = {
theme ,
setTheme : ( theme : Theme ) => {
localStorage . setItem ( storageKey , theme )
setTheme ( theme )
},
}
return (
< ThemeProviderContext.Provider { ... props } value = { value } >
{ children }
</ ThemeProviderContext.Provider >
)
}
export const useTheme = () => {
const context = useContext ( ThemeProviderContext )
if ( context === undefined )
throw new Error ( "useTheme must be used within a ThemeProvider" )
return context
}
How It Works This provider implementation includes:
TypeScript Types : Strongly typed theme values and provider props
localStorage Integration : Persists user theme preference across sessions
System Preference Detection : Uses matchMedia to detect OS dark mode preference
CSS Class Management : Applies dark or light classes to the HTML root element
Custom Hook : Provides useTheme hook for accessing theme state in components
Make sure your Tailwind CSS configuration has dark mode set to class strategy: module . exports = {
darkMode: 'class' ,
// ... rest of config
}
Wrap Your Application
Add the ThemeProvider to your main application component to make the theme context available throughout your app. import { ThemeProvider } from "@/components/theme-provider"
function App () {
return (
< ThemeProvider defaultTheme = "dark" storageKey = "vite-ui-theme" >
{ /* Your app content */ }
< div className = "min-h-screen bg-white dark:bg-slate-950" >
< YourComponents />
</ div >
</ ThemeProvider >
)
}
export default App
Provider Configuration The ThemeProvider accepts the following props: Prop Type Default Description childrenReactNoderequired Your application components defaultTheme"light" | "dark" | "system""system"Initial theme if no saved preference storageKeystring"vite-ui-theme"localStorage key for persistence
Create Mode Toggle Component
Build a UI component that allows users to switch between light, dark, and system themes. components/mode-toggle.tsx
import * as React from "react"
import { MoonIcon , SunIcon } from "lucide-react"
import { useTheme } from "@/components/theme-provider"
import { Button } from "@/components/ui/button"
import {
DropdownMenu ,
DropdownMenuContent ,
DropdownMenuItem ,
DropdownMenuTrigger ,
} from "@/components/ui/dropdown-menu"
export default function ModeToggle () {
const { setTheme } = useTheme ()
return (
< DropdownMenu >
< DropdownMenuTrigger asChild >
< Button variant = "ghost" size = "icon" className = "h-9 w-9" >
< SunIcon className = "h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
< MoonIcon className = "absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
< span className = "sr-only" > Toggle theme </ span >
</ Button >
</ DropdownMenuTrigger >
< DropdownMenuContent align = "end" >
< DropdownMenuItem onClick = { () => setTheme ( "light" ) } >
Light
</ DropdownMenuItem >
< DropdownMenuItem onClick = { () => setTheme ( "dark" ) } >
Dark
</ DropdownMenuItem >
< DropdownMenuItem onClick = { () => setTheme ( "system" ) } >
System
</ DropdownMenuItem >
</ DropdownMenuContent >
</ DropdownMenu >
)
}
Alternative: Simple Toggle Button If you prefer a simpler toggle without a dropdown: components/simple-toggle.tsx
import { Moon , Sun } from "lucide-react"
import { useTheme } from "@/components/theme-provider"
import { Button } from "@/components/ui/button"
export function SimpleToggle () {
const { theme , setTheme } = useTheme ()
const toggleTheme = () => {
if ( theme === "light" ) {
setTheme ( "dark" )
} else if ( theme === "dark" ) {
setTheme ( "system" )
} else {
setTheme ( "light" )
}
}
return (
< Button
variant = "ghost"
size = "icon"
onClick = { toggleTheme }
aria-label = "Toggle theme"
>
{ theme === "dark" ? (
< Moon className = "h-5 w-5" />
) : (
< Sun className = "h-5 w-5" />
) }
</ Button >
)
}
Add Toggle to Your Layout
Place the mode toggle component in your application layout, typically in the header or navigation bar. import ModeToggle from "@/components/mode-toggle"
export function Header () {
return (
< header className = "sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60" >
< div className = "container flex h-14 items-center justify-between" >
< div className = "flex items-center gap-2" >
< h1 className = "text-xl font-bold" > My Vite App </ h1 >
</ div >
< nav className = "flex items-center gap-4" >
< ModeToggle />
</ nav >
</ div >
</ header >
)
}
Using the Theme Hook
Access and control the theme from any component using the useTheme hook:
import { useTheme } from "@/components/theme-provider"
export function ThemeDisplay () {
const { theme , setTheme } = useTheme ()
return (
< div className = "p-4" >
< p className = "mb-4" > Current theme: < strong > { theme } </ strong ></ p >
< div className = "flex gap-2" >
< button
onClick = { () => setTheme ( "light" ) }
className = "px-4 py-2 rounded bg-gray-200 dark:bg-gray-700"
>
Light
</ button >
< button
onClick = { () => setTheme ( "dark" ) }
className = "px-4 py-2 rounded bg-gray-200 dark:bg-gray-700"
>
Dark
</ button >
< button
onClick = { () => setTheme ( "system" ) }
className = "px-4 py-2 rounded bg-gray-200 dark:bg-gray-700"
>
System
</ button >
</ div >
</ div >
)
}
Styling Components for Dark Mode
Use Tailwind’s dark: variant to apply dark mode styles:
export function Card () {
return (
< div className = "rounded-lg border bg-white p-6 shadow-sm dark:bg-slate-900 dark:border-slate-800" >
< h3 className = "text-lg font-semibold text-gray-900 dark:text-gray-100" >
Card Title
</ h3 >
< p className = "mt-2 text-gray-600 dark:text-gray-400" >
Card content that adapts to the theme automatically.
</ p >
</ div >
)
}
Advanced Features
Detect System Theme Changes
Listen for system theme changes and update automatically:
import { useEffect } from "react"
import { useTheme } from "@/components/theme-provider"
export function useSystemTheme () {
const { theme , setTheme } = useTheme ()
useEffect (() => {
if ( theme !== "system" ) return
const mediaQuery = window . matchMedia ( "(prefers-color-scheme: dark)" )
const handleChange = () => {
// Force re-render by setting theme to system again
setTheme ( "system" )
}
mediaQuery . addEventListener ( "change" , handleChange )
return () => mediaQuery . removeEventListener ( "change" , handleChange )
}, [ theme , setTheme ])
}
Custom Theme Transitions
Add smooth transitions when switching themes:
/* Smooth transitions for theme changes */
* {
transition : background-color 0.3 s ease , color 0.3 s ease , border-color 0.3 s ease ;
}
/* Disable transitions for reduced motion preference */
@media (prefers-reduced-motion: reduce) {
* {
transition : none !important ;
}
}
Theme-Specific Images
Show different images based on the current theme:
import { useTheme } from "@/components/theme-provider"
export function Logo () {
const { theme } = useTheme ()
const [ resolvedTheme , setResolvedTheme ] = useState < "light" | "dark" >( "light" )
useEffect (() => {
if ( theme === "system" ) {
const isDark = window . matchMedia ( "(prefers-color-scheme: dark)" ). matches
setResolvedTheme ( isDark ? "dark" : "light" )
} else {
setResolvedTheme ( theme as "light" | "dark" )
}
}, [ theme ])
return (
< img
src = { resolvedTheme === "dark" ? "/logo-dark.png" : "/logo-light.png" }
alt = "Logo"
/>
)
}
Troubleshooting
Theme not persisting after refresh
Verify that:
localStorage is available in your browser (not in incognito mode)
The storageKey is consistent across your app
localStorage isn’t being cleared by other code or browser extensions
Flash of wrong theme on load
This is expected behavior with client-side theme detection. To minimize it:
Set a default theme that matches most users’ preference
Add a loading state or skeleton while the theme initializes
Consider adding an inline script in index.html to set theme before React loads:
< script >
const theme = localStorage . getItem ( 'vite-ui-theme' ) || 'system'
if ( theme === 'dark' || ( theme === 'system' && window . matchMedia ( '(prefers-color-scheme: dark)' ). matches )) {
document . documentElement . classList . add ( 'dark' )
}
</ script >
Dark mode styles not applying
Check that:
Tailwind CSS is configured with darkMode: 'class'
You’re using the dark: prefix in your class names
The dark class is being applied to the <html> element (inspect in DevTools)
Your Tailwind configuration includes all necessary paths in the content array
Testing
Test your dark mode implementation:
import { render , screen , fireEvent } from "@testing-library/react"
import { ThemeProvider , useTheme } from "@/components/theme-provider"
function TestComponent () {
const { theme , setTheme } = useTheme ()
return (
< div >
< span data-testid = "theme" > { theme } </ span >
< button onClick = { () => setTheme ( "dark" ) } > Dark </ button >
< button onClick = { () => setTheme ( "light" ) } > Light </ button >
</ div >
)
}
test ( "theme can be changed" , () => {
render (
< ThemeProvider >
< TestComponent />
</ ThemeProvider >
)
fireEvent . click ( screen . getByText ( "Dark" ))
expect ( screen . getByTestId ( "theme" )). toHaveTextContent ( "dark" )
fireEvent . click ( screen . getByText ( "Light" ))
expect ( screen . getByTestId ( "theme" )). toHaveTextContent ( "light" )
})
Next Steps