← full-stack-fastapi-template  /  frontend/src/components/theme-provider.tsx

1
import {
2
  createContext,
3
  useCallback,
4
  useContext,
5
  useEffect,
6
  useState,
7
} from "react"
8
9
export type Theme = "dark" | "light" | "system"
10
11
type ThemeProviderProps = {
12
  children: React.ReactNode
13
  defaultTheme?: Theme
14
  storageKey?: string
15
}
16
17
type ThemeProviderState = {
18
  theme: Theme
19
  resolvedTheme: "dark" | "light"
20
  setTheme: (theme: Theme) => void
21
}
22
23
const initialState: ThemeProviderState = {
24
  theme: "system",
25
  resolvedTheme: "light",
26
  setTheme: () => null,
27
}
28
29
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
30
31
export function ThemeProvider({
32
  children,
33
  defaultTheme = "system",
34
  storageKey = "vite-ui-theme",
35
  ...props
36
}: ThemeProviderProps) {
37
  const [theme, setTheme] = useState<Theme>(
38
    () => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
39
  )
40
41
  const getResolvedTheme = useCallback((theme: Theme): "dark" | "light" => {
42
    if (theme === "system") {
43
      return window.matchMedia("(prefers-color-scheme: dark)").matches
44
        ? "dark"
45
        : "light"
46
    }
47
    return theme
48
  }, [])
49
50
  const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">(() =>
51
    getResolvedTheme(theme),
52
  )
53
54
  const updateTheme = useCallback((newTheme: Theme) => {
55
    const root = window.document.documentElement
56
57
    root.classList.remove("light", "dark")
58
59
    if (newTheme === "system") {
60
      const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
61
        .matches
62
        ? "dark"
63
        : "light"
64
65
      root.classList.add(systemTheme)
66
      return
67
    }
68
69
    root.classList.add(newTheme)
70
  }, [])
71
72
  useEffect(() => {
73
    updateTheme(theme)
74
    setResolvedTheme(getResolvedTheme(theme))
75
76
    const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
77
78
    const handleChange = () => {
79
      if (theme === "system") {
80
        updateTheme("system")
81
        setResolvedTheme(getResolvedTheme("system"))
82
      }
83
    }
84
85
    mediaQuery.addEventListener("change", handleChange)
86
87
    return () => {
88
      mediaQuery.removeEventListener("change", handleChange)
89
    }
90
  }, [theme, updateTheme, getResolvedTheme])
91
92
  const value = {
93
    theme,
94
    resolvedTheme,
95
    setTheme: (theme: Theme) => {
96
      localStorage.setItem(storageKey, theme)
97
      setTheme(theme)
98
    },
99
  }
100
101
  return (
102
    <ThemeProviderContext.Provider {...props} value={value}>
103
      {children}
104
    </ThemeProviderContext.Provider>
105
  )
106
}
107
108
export const useTheme = () => {
109
  const context = useContext(ThemeProviderContext)
110
111
  if (context === undefined)
112
    throw new Error("useTheme must be used within a ThemeProvider")
113
114
  return context
115
}
116