Skip to content

Commit c97c4f4

Browse files
committed
Simplify theme toggle
1 parent c10def4 commit c97c4f4

File tree

2 files changed

+42
-175
lines changed

2 files changed

+42
-175
lines changed
Lines changed: 41 additions & 174 deletions
Original file line numberDiff line numberDiff line change
@@ -1,209 +1,76 @@
11
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-
*/
2+
import { createContext, use, useEffect, useState } from "react"
543

554
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-
}
635

64-
interface ThemeProviderState {
6+
const ThemeContext = createContext<{
657
theme: Theme
668
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
9+
}>({
10+
theme: "system",
11+
setTheme: () => {
12+
throw new Error("useTheme must be used within a ThemeProvider")
13+
}
14+
})
7615

7716
export function ThemeProvider({
7817
children,
7918
defaultTheme = "system",
8019
storageKey = "theme"
81-
}: ThemeProviderProps) {
20+
}: {
21+
children: React.ReactNode
22+
defaultTheme?: Theme
23+
storageKey?: string
24+
}) {
8225
const [theme, setThemeState] = useState<Theme>(() => {
8326
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-
}
27+
return (localStorage.getItem(storageKey) as Theme) || defaultTheme
9128
})
9229

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)
30+
const setTheme = (newTheme: Theme) => {
31+
setThemeState(newTheme)
32+
if (newTheme === "system") {
33+
localStorage.removeItem(storageKey)
34+
} else {
35+
localStorage.setItem(storageKey, newTheme)
10736
}
37+
}
10838

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
16239
useEffect(() => {
163-
const handleStorage = (e: StorageEvent) => {
164-
if (e.key === storageKey) {
165-
const newTheme = (e.newValue as Theme) || defaultTheme
166-
setThemeState(newTheme)
167-
}
40+
const apply = () => {
41+
const isDark =
42+
theme === "dark" ||
43+
(theme === "system" &&
44+
matchMedia("(prefers-color-scheme: dark)").matches)
45+
46+
document.documentElement.classList.toggle("dark", isDark)
47+
document.documentElement.style.colorScheme = isDark ? "dark" : "light"
16848
}
16949

170-
window.addEventListener("storage", handleStorage)
171-
return () => window.removeEventListener("storage", handleStorage)
172-
}, [storageKey, defaultTheme])
50+
apply()
17351

174-
const value = useMemo(
175-
() => ({
176-
theme,
177-
setTheme,
178-
resolvedTheme: theme === "system" ? resolvedTheme : theme
179-
}),
180-
[theme, setTheme, resolvedTheme]
181-
)
52+
if (theme === "system") {
53+
const media = matchMedia("(prefers-color-scheme: dark)")
54+
media.addEventListener("change", apply)
55+
return () => media.removeEventListener("change", apply)
56+
}
57+
}, [theme])
18258

18359
return (
184-
<ThemeProviderContext value={value}>
60+
<ThemeContext value={{ theme, setTheme }}>
18561
<ScriptOnce>
18662
{`(() => {
18763
const stored = localStorage.getItem('${storageKey}');
18864
const theme = stored || '${defaultTheme}';
18965
const isDark = theme === 'dark' ||
190-
(theme === 'system' && window.matchMedia('${MEDIA}').matches);
191-
192-
document.documentElement.classList.add(isDark ? 'dark' : 'light');
66+
(theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
67+
document.documentElement.classList.toggle('dark', isDark);
19368
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
19469
})()`}
19570
</ScriptOnce>
19671
{children}
197-
</ThemeProviderContext>
72+
</ThemeContext>
19873
)
19974
}
20075

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-
}
76+
export const useTheme = () => use(ThemeContext)

src/lib/seo.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function seo({
1919
const tags = [
2020
{ title: getTitleWithTemplate(title) },
2121
{ name: "description", content: description },
22-
{ name: "keywords", content: keywords },
22+
...(keywords ? [{ name: "keywords", content: keywords }] : []),
2323
{ name: "twitter:title", content: title },
2424
{ name: "twitter:description", content: description },
2525
{ name: "twitter:creator", content: siteConfig.author.twitter },

0 commit comments

Comments
 (0)