Skip to content

Commit 8e1a0a1

Browse files
committed
Simplify theme provider
1 parent 38d7481 commit 8e1a0a1

File tree

6 files changed

+220
-382
lines changed

6 files changed

+220
-382
lines changed

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,16 @@
1717

1818
To get started, simply clone the repository and run `bun install`:
1919

20+
1. Clone the project or [use the template](https://github.com/new?template_owner=startkit-dev&template_name=startkit)
21+
2022
```sh
21-
git clone git@github.com:startkit-dev/startkit.git new-project
23+
npx gitpick startkit-dev/startkit my-app
24+
cd my-app
25+
```
2226

23-
cd ./new-project
27+
2. Install the dependencies
28+
29+
```sh
2430
bun install
2531
```
2632

src/components/layout/header.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ThemePicker } from "../themes/theme-picker"
1+
import { ThemePicker } from "@/components/theme/theme-picker"
22

33
export function Header() {
44
return (
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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

Comments
 (0)