Skip to main content
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

1

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:
tailwind.config.js
module.exports = {
  darkMode: 'class',
  // ... rest of config
}
2

Wrap Your Application

Add the ThemeProvider to your main application component to make the theme context available throughout your app.
src/App.tsx
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:
PropTypeDefaultDescription
childrenReactNoderequiredYour application components
defaultTheme"light" | "dark" | "system""system"Initial theme if no saved preference
storageKeystring"vite-ui-theme"localStorage key for persistence
3

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>
  )
}
4

Add Toggle to Your Layout

Place the mode toggle component in your application layout, typically in the header or navigation bar.
components/Header.tsx
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:
src/index.css
/* Smooth transitions for theme changes */
* {
  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s 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

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
This is expected behavior with client-side theme detection. To minimize it:
  1. Set a default theme that matches most users’ preference
  2. Add a loading state or skeleton while the theme initializes
  3. Consider adding an inline script in index.html to set theme before React loads:
index.html
<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>
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:
__tests__/theme.test.tsx
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

Build docs developers (and LLMs) love