Skip to content

Commit d97b8d1

Browse files
authored
feat: handle theme with localStorage (#456)
1 parent 724d334 commit d97b8d1

File tree

5 files changed

+169
-141
lines changed

5 files changed

+169
-141
lines changed

src/components/ThemeProvider.tsx

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { ScriptOnce } from '@tanstack/react-router'
2+
import { clientOnly, createIsomorphicFn } from '@tanstack/react-start'
3+
import * as React from 'react'
4+
import { createContext, ReactNode, useEffect, useState } from 'react'
5+
import { z } from 'zod'
6+
7+
const themeModeSchema = z.enum(['light', 'dark', 'auto'])
8+
const resolvedThemeSchema = z.enum(['light', 'dark'])
9+
const themeKey = 'theme'
10+
11+
type ThemeMode = z.infer<typeof themeModeSchema>
12+
type ResolvedTheme = z.infer<typeof resolvedThemeSchema>
13+
14+
const getStoredThemeMode = createIsomorphicFn()
15+
.server((): ThemeMode => 'auto')
16+
.client((): ThemeMode => {
17+
try {
18+
const storedTheme = localStorage.getItem(themeKey)
19+
return themeModeSchema.parse(storedTheme)
20+
} catch {
21+
return 'auto'
22+
}
23+
})
24+
25+
const setStoredThemeMode = clientOnly((theme: ThemeMode) => {
26+
try {
27+
const parsedTheme = themeModeSchema.parse(theme)
28+
localStorage.setItem(themeKey, parsedTheme)
29+
} catch {}
30+
})
31+
32+
const getSystemTheme = createIsomorphicFn()
33+
.server((): ResolvedTheme => 'light')
34+
.client((): ResolvedTheme => {
35+
return window.matchMedia('(prefers-color-scheme: dark)').matches
36+
? 'dark'
37+
: 'light'
38+
})
39+
40+
const updateThemeClass = clientOnly((themeMode: ThemeMode) => {
41+
const root = document.documentElement
42+
root.classList.remove('light', 'dark', 'auto')
43+
const newTheme = themeMode === 'auto' ? getSystemTheme() : themeMode
44+
root.classList.add(newTheme)
45+
46+
if (themeMode === 'auto') {
47+
root.classList.add('auto')
48+
}
49+
})
50+
51+
const setupPreferredListener = clientOnly(() => {
52+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
53+
const handler = () => updateThemeClass('auto')
54+
mediaQuery.addEventListener('change', handler)
55+
return () => mediaQuery.removeEventListener('change', handler)
56+
})
57+
58+
const getNextTheme = clientOnly((current: ThemeMode): ThemeMode => {
59+
const themes: ThemeMode[] =
60+
getSystemTheme() === 'dark'
61+
? ['auto', 'light', 'dark']
62+
: ['auto', 'dark', 'light']
63+
return themes[(themes.indexOf(current) + 1) % themes.length]
64+
})
65+
66+
const themeDetectorScript = (function () {
67+
function themeFn() {
68+
try {
69+
const storedTheme = localStorage.getItem('theme') || 'auto'
70+
const validTheme = ['light', 'dark', 'auto'].includes(storedTheme)
71+
? storedTheme
72+
: 'auto'
73+
74+
if (validTheme === 'auto') {
75+
const autoTheme = window.matchMedia('(prefers-color-scheme: dark)')
76+
.matches
77+
? 'dark'
78+
: 'light'
79+
document.documentElement.classList.add(autoTheme, 'auto')
80+
} else {
81+
document.documentElement.classList.add(validTheme)
82+
}
83+
} catch (e) {
84+
const autoTheme = window.matchMedia('(prefers-color-scheme: dark)')
85+
.matches
86+
? 'dark'
87+
: 'light'
88+
document.documentElement.classList.add(autoTheme, 'auto')
89+
}
90+
}
91+
return `(${themeFn.toString()})();`
92+
})()
93+
94+
type ThemeContextProps = {
95+
themeMode: ThemeMode
96+
resolvedTheme: ResolvedTheme
97+
setTheme: (theme: ThemeMode) => void
98+
toggleMode: () => void
99+
}
100+
const ThemeContext = createContext<ThemeContextProps | undefined>(undefined)
101+
102+
type ThemeProviderProps = {
103+
children: ReactNode
104+
}
105+
export function ThemeProvider({ children }: ThemeProviderProps) {
106+
const [themeMode, setThemeMode] = useState<ThemeMode>(getStoredThemeMode)
107+
108+
useEffect(() => {
109+
if (themeMode !== 'auto') return
110+
return setupPreferredListener()
111+
}, [themeMode])
112+
113+
const resolvedTheme = themeMode === 'auto' ? getSystemTheme() : themeMode
114+
115+
const setTheme = (newTheme: ThemeMode) => {
116+
setThemeMode(newTheme)
117+
setStoredThemeMode(newTheme)
118+
updateThemeClass(newTheme)
119+
}
120+
121+
const toggleMode = () => {
122+
setTheme(getNextTheme(themeMode))
123+
}
124+
125+
return (
126+
<ThemeContext.Provider
127+
value={{ themeMode, resolvedTheme, setTheme, toggleMode }}
128+
>
129+
<ScriptOnce children={themeDetectorScript} />
130+
{children}
131+
</ThemeContext.Provider>
132+
)
133+
}
134+
135+
export const useTheme = () => {
136+
const context = React.useContext(ThemeContext)
137+
if (!context) {
138+
throw new Error('useTheme must be used within a ThemeProvider')
139+
}
140+
return context
141+
}

src/components/ThemeToggle.tsx

Lines changed: 10 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,9 @@
1-
import { createServerFn } from '@tanstack/react-start'
2-
import { getCookie, setCookie } from '@tanstack/react-start/server'
31
import * as React from 'react'
42
import { FaMoon, FaSun } from 'react-icons/fa'
5-
import { twMerge } from 'tailwind-merge'
6-
7-
import { z } from 'zod'
8-
import { create } from 'zustand'
9-
10-
const themeModeSchema = z.enum(['light', 'dark', 'auto'])
11-
const prefersModeSchema = z.enum(['light', 'dark'])
12-
13-
type ThemeMode = z.infer<typeof themeModeSchema>
14-
type PrefersMode = z.infer<typeof prefersModeSchema>
15-
16-
interface ThemeStore {
17-
mode: ThemeMode
18-
prefers: PrefersMode
19-
toggleMode: () => void
20-
setPrefers: (prefers: PrefersMode) => void
21-
}
22-
23-
const updateThemeCookie = createServerFn({ method: 'POST' })
24-
.validator(themeModeSchema)
25-
.handler((ctx) => {
26-
setCookie('theme', ctx.data, {
27-
httpOnly: false,
28-
sameSite: 'lax',
29-
secure: process.env.NODE_ENV === 'production',
30-
path: '/',
31-
maxAge: 60 * 60 * 24 * 365 * 10,
32-
})
33-
})
34-
35-
export const getThemeCookie = createServerFn().handler(() => {
36-
return (
37-
themeModeSchema.catch('auto').parse(getCookie('theme') ?? 'null') || 'auto'
38-
)
39-
})
40-
41-
export const useThemeStore = create<ThemeStore>((set, get) => ({
42-
mode: 'auto',
43-
prefers: (() => {
44-
if (typeof document !== 'undefined') {
45-
return window.matchMedia('(prefers-color-scheme: dark)').matches
46-
? 'dark'
47-
: 'light'
48-
}
49-
50-
return 'light'
51-
})(),
52-
toggleMode: () =>
53-
set((s) => {
54-
const newMode =
55-
s.mode === 'auto' ? 'light' : s.mode === 'light' ? 'dark' : 'auto'
56-
57-
updateThemeClass(newMode, s.prefers)
58-
updateThemeCookie({
59-
data: newMode,
60-
})
61-
62-
return {
63-
mode: newMode,
64-
}
65-
}),
66-
setPrefers: (prefers) => {
67-
set({ prefers })
68-
updateThemeClass(get().mode, prefers)
69-
},
70-
}))
71-
72-
if (typeof document !== 'undefined') {
73-
window
74-
.matchMedia('(prefers-color-scheme: dark)')
75-
.addEventListener('change', (event) => {
76-
if (useThemeStore.getState().mode === 'auto') {
77-
}
78-
useThemeStore.getState().setPrefers(event.matches ? 'dark' : 'light')
79-
})
80-
}
81-
82-
// Helper to update <body> class
83-
function updateThemeClass(mode: ThemeMode, prefers: PrefersMode) {
84-
document.documentElement.classList.remove('dark')
85-
if (mode === 'dark' || (mode === 'auto' && prefers === 'dark')) {
86-
document.documentElement.classList.add('dark')
87-
}
88-
}
3+
import { useTheme } from './ThemeProvider'
894

905
export function ThemeToggle() {
91-
const mode = useThemeStore((s) => s.mode)
92-
const toggleMode = useThemeStore((s) => s.toggleMode)
6+
const { toggleMode } = useTheme()
937

948
const handleToggleMode = (
959
e: React.MouseEvent<HTMLDivElement, MouseEvent>
@@ -102,40 +16,26 @@ export function ThemeToggle() {
10216
return (
10317
<div
10418
onClick={handleToggleMode}
105-
className={twMerge(
106-
`w-12 h-6 bg-gray-500/10 dark:bg-gray-800 rounded-full flex items-center justify-between cursor-pointer relative transition-all`
107-
)}
19+
className={`w-12 h-6 bg-gray-500/10 dark:bg-gray-800 rounded-full flex items-center justify-between cursor-pointer relative transition-all`}
10820
>
10921
<div className="flex-1 flex items-center justify-between px-1.5">
11022
<FaSun
111-
className={twMerge(
112-
`text-sm transition-opacity`,
113-
mode !== 'auto' ? 'opacity-50' : 'opacity-0'
114-
)}
23+
className={`text-sm transition-opacity auto:opacity-0 opacity-50`}
11524
/>
11625
<FaMoon
117-
className={twMerge(
118-
`text-sm transition-opacity`,
119-
mode !== 'auto' ? 'opacity-50' : 'opacity-0'
120-
)}
26+
className={`text-sm transition-opacity auto:opacity-0 opacity-50`}
12127
/>
12228
<span
123-
className={twMerge(
124-
`uppercase select-none font-black text-[.6rem] absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transition-opacity`,
125-
mode === 'auto' ? 'opacity-30 hover:opacity-50' : 'opacity-0'
126-
)}
29+
className={`uppercase select-none font-black text-[.6rem] absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transition-opacity auto:opacity-30 auto:hover:opacity-50 opacity-0`}
12730
>
12831
Auto
12932
</span>
13033
</div>
13134
<div
132-
className="absolute w-6 h-6 rounded-full shadow-md shadow-black/20 bg-white dark:bg-gray-400 transition-all duration-300 ease-in-out"
133-
style={{
134-
left: mode === 'auto' ? '50%' : mode === 'light' ? '100%' : '0%',
135-
transform: `translateX(${
136-
mode === 'auto' ? '-50%' : mode === 'light' ? '-100%' : '0'
137-
}) scale(${mode === 'auto' ? 0 : 0.8})`,
138-
}}
35+
className="absolute w-6 h-6 rounded-full shadow-md shadow-black/20 bg-white dark:bg-gray-400 transition-all duration-300 ease-in-out
36+
auto:left-1/2 auto:-translate-x-1/2 auto:scale-0 auto:opacity-0
37+
left-0 translate-x-full scale-75
38+
dark:left-0 dark:translate-x-0 dark:scale-75"
13939
/>
14040
</div>
14141
)

src/routes/__root.tsx

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import * as React from 'react'
2-
import * as ReactDom from 'react-dom'
32
import {
43
Outlet,
5-
ScriptOnce,
64
createRootRouteWithContext,
75
redirect,
86
useMatches,
@@ -20,11 +18,11 @@ import { TanStackRouterDevtoolsInProd } from '@tanstack/react-router-devtools'
2018
import { NotFound } from '~/components/NotFound'
2119
import { CgSpinner } from 'react-icons/cg'
2220
import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary'
23-
import { getThemeCookie, useThemeStore } from '~/components/ThemeToggle'
2421
import { GamScripts } from '~/components/Gam'
2522
import { BackgroundAnimation } from '~/components/BackgroundAnimation'
2623
import { SearchProvider } from '~/contexts/SearchContext'
2724
import { SearchModal } from '~/components/SearchModal'
25+
import { ThemeProvider } from '~/components/ThemeProvider'
2826

2927
export const Route = createRootRouteWithContext<{
3028
queryClient: QueryClient
@@ -127,11 +125,6 @@ export const Route = createRootRouteWithContext<{
127125
}
128126
},
129127
staleTime: Infinity,
130-
loader: async () => {
131-
return {
132-
themeCookie: await getThemeCookie(),
133-
}
134-
},
135128
errorComponent: (props) => {
136129
return (
137130
<RootDocument>
@@ -159,23 +152,18 @@ function RootComponent() {
159152

160153
return (
161154
<ClerkProvider publishableKey={PUBLISHABLE_KEY}>
162-
<SearchProvider>
163-
<RootDocument>
164-
<Outlet />
165-
</RootDocument>
166-
</SearchProvider>
155+
<ThemeProvider>
156+
<SearchProvider>
157+
<RootDocument>
158+
<Outlet />
159+
</RootDocument>
160+
</SearchProvider>
161+
</ThemeProvider>
167162
</ClerkProvider>
168163
)
169164
}
170165

171166
function RootDocument({ children }: { children: React.ReactNode }) {
172-
const { themeCookie } = Route.useLoaderData()
173-
174-
React.useEffect(() => {
175-
useThemeStore.setState({ mode: themeCookie })
176-
// eslint-disable-next-line react-hooks/exhaustive-deps
177-
}, [])
178-
179167
const matches = useMatches()
180168

181169
const isLoading = useRouterState({
@@ -200,17 +188,9 @@ function RootDocument({ children }: { children: React.ReactNode }) {
200188

201189
const showDevtools = canShowLoading && isRouterPage
202190

203-
const themeClass = themeCookie === 'dark' ? 'dark' : ''
204-
205191
return (
206-
<html lang="en" className={themeClass}>
192+
<html lang="en" suppressHydrationWarning>
207193
<head>
208-
{/* If the theme is set to auto, inject a tiny script to set the proper class on html based on the user preference */}
209-
{themeCookie === 'auto' ? (
210-
<ScriptOnce
211-
children={`window.matchMedia('(prefers-color-scheme: dark)').matches ? document.documentElement.classList.add('dark') : null`}
212-
/>
213-
) : null}
214194
<HeadContent />
215195
{matches.find((d) => d.staticData?.baseParent) ? (
216196
<base target="_parent" />

src/routes/_libraries/route.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
import { getSponsorsForSponsorPack } from '~/server/sponsors'
1919
import { libraries } from '~/libraries'
2020
import { Scarf } from '~/components/Scarf'
21-
import { ThemeToggle, useThemeStore } from '~/components/ThemeToggle'
21+
import { ThemeToggle } from '~/components/ThemeToggle'
2222
import { TbBrandBluesky, TbBrandTwitter } from 'react-icons/tb'
2323
import { BiSolidCheckShield } from 'react-icons/bi'
2424
import { SearchButton } from '~/components/SearchButton'

tailwind.config.cjs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
/** @type {import('tailwindcss').Config} */
22
module.exports = {
33
content: ['./src/**/*.{js,ts,jsx,tsx}'],
4-
plugins: [require('@tailwindcss/typography')],
4+
plugins: [
5+
require('@tailwindcss/typography'),
6+
function ({ addVariant }) {
7+
addVariant('light', '&:is(.light *)')
8+
addVariant('dark', '&:is(.dark *)')
9+
addVariant('auto', '&:is(.auto *)')
10+
},
11+
],
512
darkMode: 'class',
613
theme: {
714
extend: {

0 commit comments

Comments
 (0)