Vite
Implement dark mode in your Vite project.
Implementation in Vite (React)
For Vite projects using React, the most effective approach is to use a custom Context Provider to manage the theme state and persist it to localStorage.
Create the ThemeProvider
Create a theme-provider.tsx file in your components folder to manage the theme state and sync it with the document’s root element:
src/components/theme-provider.tsx
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
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}12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
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}If you see a Vite Fast Refresh warning, you can safely ignore it by adding // eslint-disable-next-line react-refresh/only-export-components directly above the export const useTheme line.
Wrap your application
Add the ThemeProvider to your main entry point, typically main.tsx or App.tsx:
src/main.tsx
12345678910111213
import { StrictMode } from 'react'import { createRoot } from 'react-dom/client'import './index.css'import App from './App.tsx'import { ThemeProvider } from "@/components/theme-provider"
createRoot(document.getElementById('root')!).render( <StrictMode> <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> <App /> </ThemeProvider> </StrictMode>,)12345678910111213
import { StrictMode } from 'react'import { createRoot } from 'react-dom/client'import './index.css'import App from './App.tsx'import { ThemeProvider } from "@/components/theme-provider"
createRoot(document.getElementById('root')!).render( <StrictMode> <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> <App /> </ThemeProvider> </StrictMode>,)Add a Theme Toggle
Use the ThemeToggle component to allow users to switch between themes.
components/theme-toggle.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
import { useState, useEffect } from "react";import { Sun, Monitor, Moon } from "lucide-react";import { useTheme } from "@/components/theme-provider";import { cn } from "@/lib/utils";
type Theme = "light" | "system" | "dark";
const LENS_POSITIONS: Record<Theme, string> = { light: "translate-x-0", system: "translate-x-full", dark: "translate-x-[200%]",};
const THEME_OPTIONS = [ { value: "light", icon: Sun, label: "Light theme" }, { value: "system", icon: Monitor, label: "System theme" }, { value: "dark", icon: Moon, label: "Dark theme" },] as const;
export default function ThemeToggle({ className }: { className?: string }) { const [mounted, setMounted] = useState(false); const { theme, setTheme } = useTheme();
useEffect(() => { const timer = setTimeout(() => setMounted(true), 0); return () => clearTimeout(timer); }, []);
return ( <div className={cn( "relative border border-glass-border shadow-glass-sm w-[97px] h-8 flex items-center rounded-glass-sm", className )} role="radiogroup" aria-label="Theme toggle" > <div className={cn( "absolute w-8 h-8 rounded-glass-sm z-0 glass", mounted ? "transition-transform duration-300 cubic-bezier(0.4, 0.0, 0.2, 1)" : "", LENS_POSITIONS[theme as Theme] )} />
{THEME_OPTIONS.map(({ value, icon: Icon, label }) => ( <button key={value} onClick={() => setTheme(value as Theme)} className={cn( "flex z-10 h-8 w-8 rounded-glass-sm transition-colors duration-200 justify-center items-center", theme === value ? "text-foreground" : "text-muted-foreground hover:text-foreground" )} aria-label={label} role="radio" aria-checked={theme === value} > <Icon className="h-4 w-4" /> </button> ))} </div> );}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
import { useState, useEffect } from "react";import { Sun, Monitor, Moon } from "lucide-react";import { useTheme } from "@/components/theme-provider";import { cn } from "@/lib/utils";
type Theme = "light" | "system" | "dark";
const LENS_POSITIONS: Record<Theme, string> = { light: "translate-x-0", system: "translate-x-full", dark: "translate-x-[200%]",};
const THEME_OPTIONS = [ { value: "light", icon: Sun, label: "Light theme" }, { value: "system", icon: Monitor, label: "System theme" }, { value: "dark", icon: Moon, label: "Dark theme" },] as const;
export default function ThemeToggle({ className }: { className?: string }) { const [mounted, setMounted] = useState(false); const { theme, setTheme } = useTheme();
useEffect(() => { const timer = setTimeout(() => setMounted(true), 0); return () => clearTimeout(timer); }, []);
return ( <div className={cn( "relative border border-glass-border shadow-glass-sm w-[97px] h-8 flex items-center rounded-glass-sm", className )} role="radiogroup" aria-label="Theme toggle" > <div className={cn( "absolute w-8 h-8 rounded-glass-sm z-0 glass", mounted ? "transition-transform duration-300 cubic-bezier(0.4, 0.0, 0.2, 1)" : "", LENS_POSITIONS[theme as Theme] )} />
{THEME_OPTIONS.map(({ value, icon: Icon, label }) => ( <button key={value} onClick={() => setTheme(value as Theme)} className={cn( "flex z-10 h-8 w-8 rounded-glass-sm transition-colors duration-200 justify-center items-center", theme === value ? "text-foreground" : "text-muted-foreground hover:text-foreground" )} aria-label={label} role="radio" aria-checked={theme === value} > <Icon className="h-4 w-4" /> </button> ))} </div> );}