|
| 1 | +import { ScriptOnce } from "@tanstack/react-router" |
| 2 | +import { |
| 3 | + createContext, |
| 4 | + use, |
| 5 | + useCallback, |
| 6 | + useEffect, |
| 7 | + useMemo, |
| 8 | + useState |
| 9 | +} from "react" |
| 10 | + |
| 11 | +/** |
| 12 | + * Theme provider for TanStack Start applications with dark/light mode support. |
| 13 | + * Handles system preferences, localStorage persistence, and FOUC prevention. |
| 14 | + * |
| 15 | + * @example |
| 16 | + * ```tsx |
| 17 | + * // In your root route component (e.g., __root.tsx) |
| 18 | + * import { ThemeProvider } from '@/components/theme-provider' |
| 19 | + * |
| 20 | + * export default function RootRoute() { |
| 21 | + * return ( |
| 22 | + * <ThemeProvider defaultTheme="system" storageKey="app-theme"> |
| 23 | + * <Outlet /> |
| 24 | + * </ThemeProvider> |
| 25 | + * ) |
| 26 | + * } |
| 27 | + * |
| 28 | + * // In any component |
| 29 | + * import { useTheme } from '@/components/theme-provider' |
| 30 | + * |
| 31 | + * function ThemeToggle() { |
| 32 | + * const { theme, setTheme, resolvedTheme } = useTheme() |
| 33 | + * |
| 34 | + * return ( |
| 35 | + * <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}> |
| 36 | + * Current theme: {theme} (displaying: {resolvedTheme}) |
| 37 | + * </button> |
| 38 | + * ) |
| 39 | + * } |
| 40 | + * |
| 41 | + * // For theme-aware images or icons |
| 42 | + * function Logo() { |
| 43 | + * const { resolvedTheme } = useTheme() |
| 44 | + * |
| 45 | + * return ( |
| 46 | + * <img |
| 47 | + * src={resolvedTheme === 'dark' ? '/logo-dark.svg' : '/logo-light.svg'} |
| 48 | + * alt="Logo" |
| 49 | + * /> |
| 50 | + * ) |
| 51 | + * } |
| 52 | + * ``` |
| 53 | + */ |
| 54 | + |
| 55 | +type Theme = "dark" | "light" | "system" |
| 56 | +type ResolvedTheme = "dark" | "light" |
| 57 | + |
| 58 | +interface ThemeProviderProps { |
| 59 | + children: React.ReactNode |
| 60 | + defaultTheme?: Theme |
| 61 | + storageKey?: string |
| 62 | +} |
| 63 | + |
| 64 | +interface ThemeProviderState { |
| 65 | + theme: Theme |
| 66 | + setTheme: (theme: Theme) => void |
| 67 | + resolvedTheme?: ResolvedTheme |
| 68 | +} |
| 69 | + |
| 70 | +const ThemeProviderContext = createContext<ThemeProviderState | undefined>( |
| 71 | + undefined |
| 72 | +) |
| 73 | + |
| 74 | +const MEDIA = "(prefers-color-scheme: dark)" |
| 75 | +const THEMES = ["light", "dark"] as const |
| 76 | + |
| 77 | +export function ThemeProvider({ |
| 78 | + children, |
| 79 | + defaultTheme = "system", |
| 80 | + storageKey = "theme" |
| 81 | +}: ThemeProviderProps) { |
| 82 | + const [theme, setThemeState] = useState<Theme>(() => { |
| 83 | + if (typeof window === "undefined") return defaultTheme |
| 84 | + |
| 85 | + try { |
| 86 | + const stored = localStorage.getItem(storageKey) as Theme | null |
| 87 | + return stored ?? defaultTheme |
| 88 | + } catch { |
| 89 | + return defaultTheme |
| 90 | + } |
| 91 | + }) |
| 92 | + |
| 93 | + // Track the resolved theme for "system" mode |
| 94 | + const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => { |
| 95 | + if (typeof window === "undefined") return "light" |
| 96 | + return window.matchMedia(MEDIA).matches ? "dark" : "light" |
| 97 | + }) |
| 98 | + |
| 99 | + // Apply theme to DOM |
| 100 | + const applyTheme = useCallback((targetTheme: ResolvedTheme) => { |
| 101 | + const root = document.documentElement |
| 102 | + |
| 103 | + // Only update if different |
| 104 | + if (!root.classList.contains(targetTheme)) { |
| 105 | + root.classList.remove(...THEMES) |
| 106 | + root.classList.add(targetTheme) |
| 107 | + } |
| 108 | + |
| 109 | + // Also set color-scheme for native elements |
| 110 | + root.style.colorScheme = targetTheme |
| 111 | + }, []) |
| 112 | + |
| 113 | + // Handle theme changes |
| 114 | + const setTheme = useCallback( |
| 115 | + (newTheme: Theme) => { |
| 116 | + setThemeState(newTheme) |
| 117 | + |
| 118 | + try { |
| 119 | + if (newTheme === "system") { |
| 120 | + localStorage.removeItem(storageKey) |
| 121 | + } else { |
| 122 | + localStorage.setItem(storageKey, newTheme) |
| 123 | + } |
| 124 | + } catch { |
| 125 | + // Ignore storage errors (e.g., private browsing) |
| 126 | + } |
| 127 | + }, |
| 128 | + [storageKey] |
| 129 | + ) |
| 130 | + |
| 131 | + // Handle system preference changes |
| 132 | + const handleMediaQuery = useCallback( |
| 133 | + (e: MediaQueryListEvent | MediaQueryList) => { |
| 134 | + const newResolvedTheme = e.matches ? "dark" : "light" |
| 135 | + setResolvedTheme(newResolvedTheme) |
| 136 | + |
| 137 | + if (theme === "system") { |
| 138 | + applyTheme(newResolvedTheme) |
| 139 | + } |
| 140 | + }, |
| 141 | + [theme, applyTheme] |
| 142 | + ) |
| 143 | + |
| 144 | + // Listen for system preference changes |
| 145 | + useEffect(() => { |
| 146 | + const media = window.matchMedia(MEDIA) |
| 147 | + |
| 148 | + // Modern browsers |
| 149 | + media.addEventListener("change", handleMediaQuery) |
| 150 | + handleMediaQuery(media) // Initial check |
| 151 | + |
| 152 | + return () => media.removeEventListener("change", handleMediaQuery) |
| 153 | + }, [handleMediaQuery]) |
| 154 | + |
| 155 | + // Apply theme whenever it changes |
| 156 | + useEffect(() => { |
| 157 | + const targetTheme = theme === "system" ? resolvedTheme : theme |
| 158 | + applyTheme(targetTheme) |
| 159 | + }, [theme, resolvedTheme, applyTheme]) |
| 160 | + |
| 161 | + // Handle storage events for multi-tab sync |
| 162 | + useEffect(() => { |
| 163 | + const handleStorage = (e: StorageEvent) => { |
| 164 | + if (e.key === storageKey) { |
| 165 | + const newTheme = (e.newValue as Theme) || defaultTheme |
| 166 | + setThemeState(newTheme) |
| 167 | + } |
| 168 | + } |
| 169 | + |
| 170 | + window.addEventListener("storage", handleStorage) |
| 171 | + return () => window.removeEventListener("storage", handleStorage) |
| 172 | + }, [storageKey, defaultTheme]) |
| 173 | + |
| 174 | + const value = useMemo( |
| 175 | + () => ({ |
| 176 | + theme, |
| 177 | + setTheme, |
| 178 | + resolvedTheme: theme === "system" ? resolvedTheme : theme |
| 179 | + }), |
| 180 | + [theme, setTheme, resolvedTheme] |
| 181 | + ) |
| 182 | + |
| 183 | + return ( |
| 184 | + <ThemeProviderContext value={value}> |
| 185 | + <ScriptOnce> |
| 186 | + {`(() => { |
| 187 | + const stored = localStorage.getItem('${storageKey}'); |
| 188 | + const theme = stored || '${defaultTheme}'; |
| 189 | + const isDark = theme === 'dark' || |
| 190 | + (theme === 'system' && window.matchMedia('${MEDIA}').matches); |
| 191 | +
|
| 192 | + document.documentElement.classList.add(isDark ? 'dark' : 'light'); |
| 193 | + document.documentElement.style.colorScheme = isDark ? 'dark' : 'light'; |
| 194 | + })()`} |
| 195 | + </ScriptOnce> |
| 196 | + {children} |
| 197 | + </ThemeProviderContext> |
| 198 | + ) |
| 199 | +} |
| 200 | + |
| 201 | +export function useTheme() { |
| 202 | + const context = use(ThemeProviderContext) |
| 203 | + |
| 204 | + if (!context) { |
| 205 | + throw new Error("useTheme must be used within a ThemeProvider") |
| 206 | + } |
| 207 | + |
| 208 | + return context |
| 209 | +} |
0 commit comments