Files
app/frontend/src/components/theme-provider.tsx
T
stroblmeandClaude Opus 5 da6f4b9d46 Let the theme provider render without a browser
The sibling index frontend now prerenders its public pages, where the initial
state read of localStorage and the matchMedia probe both run with no browser
present. The design contract keeps this file byte-identical across the two
repos, so the guards land here in the same change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 15:13:18 +02:00

122 lines
2.8 KiB
TypeScript

import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react"
export type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
resolvedTheme: "dark" | "light"
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
resolvedTheme: "light",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() =>
(typeof localStorage !== "undefined"
? (localStorage.getItem(storageKey) as Theme)
: null) || defaultTheme,
)
const getResolvedTheme = useCallback((theme: Theme): "dark" | "light" => {
if (theme === "system") {
// Prerendered HTML has no preference to read; the pre-paint script in
// the document head resolves it before React ever hydrates.
if (typeof window === "undefined") return "light"
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
}
return theme
}, [])
const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">(() =>
getResolvedTheme(theme),
)
const updateTheme = useCallback((newTheme: Theme) => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (newTheme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(newTheme)
}, [])
useEffect(() => {
updateTheme(theme)
setResolvedTheme(getResolvedTheme(theme))
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
const handleChange = () => {
if (theme === "system") {
updateTheme("system")
setResolvedTheme(getResolvedTheme("system"))
}
}
mediaQuery.addEventListener("change", handleChange)
return () => {
mediaQuery.removeEventListener("change", handleChange)
}
}, [theme, updateTheme, getResolvedTheme])
const value = {
theme,
resolvedTheme,
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
}