-
-
Save WellDone2094/16107a2a9476b28a5b394bee3fa1b8a3 to your computer and use it in GitHub Desktop.
| 'use client'; | |
| /* | |
| This file is adapted from next-themes to work with tanstack start. | |
| next-themes can be found at https://github.com/pacocoursey/next-themes under the MIT license. | |
| */ | |
| import * as React from 'react'; | |
| interface ValueObject { | |
| [themeName: string]: string; | |
| } | |
| export interface UseThemeProps { | |
| /** List of all available theme names */ | |
| themes: string[]; | |
| /** Forced theme name for the current page */ | |
| forcedTheme?: string | undefined; | |
| /** Update the theme */ | |
| setTheme: React.Dispatch<React.SetStateAction<string>>; | |
| /** Active theme name */ | |
| theme?: string | undefined; | |
| /** If enableSystem is true, returns the System theme preference ("dark" or "light"), regardless what the active theme is */ | |
| systemTheme?: 'dark' | 'light' | undefined; | |
| } | |
| export type Attribute = `data-${string}` | 'class'; | |
| export interface ThemeProviderProps extends React.PropsWithChildren { | |
| /** List of all available theme names */ | |
| themes?: string[] | undefined; | |
| /** Forced theme name for the current page */ | |
| forcedTheme?: string | undefined; | |
| /** Whether to switch between dark and light themes based on prefers-color-scheme */ | |
| enableSystem?: boolean | undefined; | |
| /** Disable all CSS transitions when switching themes */ | |
| disableTransitionOnChange?: boolean | undefined; | |
| /** Whether to indicate to browsers which color scheme is used (dark or light) for built-in UI like inputs and buttons */ | |
| enableColorScheme?: boolean | undefined; | |
| /** Key used to store theme setting in localStorage */ | |
| storageKey?: string | undefined; | |
| /** Default theme name (for v0.0.12 and lower the default was light). If `enableSystem` is false, the default theme is light */ | |
| defaultTheme?: string | undefined; | |
| /** HTML attribute modified based on the active theme. Accepts `class`, `data-*` (meaning any data attribute, `data-mode`, `data-color`, etc.), or an array which could include both */ | |
| attribute?: Attribute | Attribute[] | undefined; | |
| /** Mapping of theme name to HTML attribute value. Object where key is the theme name and value is the attribute value */ | |
| value?: ValueObject | undefined; | |
| /** Nonce string to pass to the inline script for CSP headers */ | |
| nonce?: string | undefined; | |
| } | |
| const colorSchemes = ['light', 'dark']; | |
| const MEDIA = '(prefers-color-scheme: dark)'; | |
| const isServer = typeof window === 'undefined'; | |
| const ThemeContext = React.createContext<UseThemeProps | undefined>(undefined); | |
| const defaultContext: UseThemeProps = { setTheme: (_) => {}, themes: [] }; | |
| export const useTheme = () => React.useContext(ThemeContext) ?? defaultContext; | |
| export const ThemeProvider = (props: ThemeProviderProps): React.ReactNode => { | |
| const context = React.useContext(ThemeContext); | |
| // Ignore nested context providers, just passthrough children | |
| if (context) return props.children; | |
| return <Theme {...props} />; | |
| }; | |
| const defaultThemes = ['light', 'dark']; | |
| const Theme = ({ | |
| forcedTheme, | |
| disableTransitionOnChange = false, | |
| enableSystem = true, | |
| enableColorScheme = true, | |
| storageKey = 'theme', | |
| themes = defaultThemes, | |
| defaultTheme = enableSystem ? 'system' : 'light', | |
| attribute = 'data-theme', | |
| value, | |
| children, | |
| nonce, | |
| }: ThemeProviderProps) => { | |
| const [theme, setThemeState] = React.useState(() => getTheme(storageKey, defaultTheme)); | |
| const attrs = !value ? themes : Object.values(value); | |
| // apply selected theme function (light, dark, system) | |
| // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> | |
| const applyTheme = React.useCallback((theme: string | undefined) => { | |
| let resolved = theme; | |
| if (!resolved) return; | |
| // If theme is system, resolve it before setting theme | |
| if (theme === 'system' && enableSystem) { | |
| resolved = getSystemTheme(); | |
| } | |
| const name = value ? value[resolved] : resolved; | |
| const enable = disableTransitionOnChange ? disableAnimation() : null; | |
| const d = document.documentElement; | |
| const handleAttribute = (attr: Attribute) => { | |
| if (attr === 'class') { | |
| d.classList.remove(...attrs); | |
| if (name) d.classList.add(name); | |
| } else if (attr.startsWith('data-')) { | |
| if (name) { | |
| d.setAttribute(attr, name); | |
| } else { | |
| d.removeAttribute(attr); | |
| } | |
| } | |
| }; | |
| if (Array.isArray(attribute)) attribute.forEach(handleAttribute); | |
| else handleAttribute(attribute); | |
| if (enableColorScheme) { | |
| const fallback = colorSchemes.includes(defaultTheme) ? defaultTheme : null; | |
| const colorScheme = colorSchemes.includes(resolved) ? resolved : fallback; | |
| // @ts-ignore | |
| d.style.colorScheme = colorScheme; | |
| } | |
| enable?.(); | |
| }, []); | |
| // Set theme state and save to local storage | |
| // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> | |
| const setTheme = React.useCallback( | |
| // biome-ignore lint/suspicious/noExplicitAny: <explanation> | |
| (value: any) => { | |
| const newTheme = typeof value === 'function' ? value(theme) : value; | |
| setThemeState(newTheme); | |
| // Save to storage | |
| try { | |
| localStorage.setItem(storageKey, newTheme); | |
| } catch (e) { | |
| // Unsupported | |
| } | |
| }, | |
| [theme], | |
| ); | |
| // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> | |
| const handleMediaQuery = React.useCallback( | |
| (e: MediaQueryListEvent | MediaQueryList) => { | |
| const resolved = getSystemTheme(e); | |
| if (theme === 'system' && enableSystem && !forcedTheme) { | |
| applyTheme('system'); | |
| } | |
| }, | |
| [theme, forcedTheme], | |
| ); | |
| // Always listen to System preference | |
| React.useEffect(() => { | |
| const media = window.matchMedia(MEDIA); | |
| // Intentionally use deprecated listener methods to support iOS & old browsers | |
| media.addListener(handleMediaQuery); | |
| handleMediaQuery(media); | |
| return () => media.removeListener(handleMediaQuery); | |
| }, [handleMediaQuery]); | |
| // localStorage event handling, allow to sync theme changes between tabs | |
| // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> | |
| React.useEffect(() => { | |
| const handleStorage = (e: StorageEvent) => { | |
| if (e.key !== storageKey) { | |
| return; | |
| } | |
| // If default theme set, use it if localstorage === null (happens on local storage manual deletion) | |
| const theme = e.newValue || defaultTheme; | |
| setTheme(theme); | |
| }; | |
| window.addEventListener('storage', handleStorage); | |
| return () => window.removeEventListener('storage', handleStorage); | |
| }, [setTheme]); | |
| // Whenever theme or forcedTheme changes, apply it | |
| // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> | |
| React.useEffect(() => { | |
| applyTheme(forcedTheme ?? theme); | |
| }, [forcedTheme, theme]); | |
| const providerValue = React.useMemo( | |
| () => ({ | |
| theme, | |
| setTheme, | |
| forcedTheme, | |
| themes: enableSystem ? [...themes, 'system'] : themes, | |
| }), | |
| [theme, setTheme, forcedTheme, enableSystem, themes], | |
| ); | |
| return ( | |
| <ThemeContext.Provider value={providerValue}> | |
| <ThemeScript | |
| {...{ | |
| forcedTheme, | |
| storageKey, | |
| attribute, | |
| enableSystem, | |
| enableColorScheme, | |
| defaultTheme, | |
| value, | |
| themes, | |
| nonce, | |
| }} | |
| /> | |
| {children} | |
| </ThemeContext.Provider> | |
| ); | |
| }; | |
| const ThemeScript = React.memo( | |
| ({ | |
| forcedTheme, | |
| storageKey, | |
| attribute, | |
| enableSystem, | |
| enableColorScheme, | |
| defaultTheme, | |
| value, | |
| themes, | |
| nonce, | |
| }: Omit<ThemeProviderProps, 'children'> & { defaultTheme: string }) => { | |
| const scriptArgs = JSON.stringify([ | |
| attribute, | |
| storageKey, | |
| defaultTheme, | |
| forcedTheme, | |
| themes, | |
| value, | |
| enableSystem, | |
| enableColorScheme, | |
| ]).slice(1, -1); | |
| return ( | |
| <script | |
| suppressHydrationWarning | |
| nonce={typeof window === 'undefined' ? nonce : ''} | |
| // biome-ignore lint/security/noDangerouslySetInnerHtml: Needed to inject script before hydration | |
| dangerouslySetInnerHTML={{ __html: `(${script.toString()})(${scriptArgs})` }} | |
| /> | |
| // <></> | |
| ); | |
| }, | |
| ); | |
| // Helpers | |
| const getTheme = (key: string, fallback?: string) => { | |
| if (isServer) return undefined; | |
| let theme: string | undefined; | |
| try { | |
| theme = localStorage.getItem(key) || undefined; | |
| } catch (e) { | |
| // Unsupported | |
| } | |
| return theme || fallback; | |
| }; | |
| const disableAnimation = () => { | |
| const css = document.createElement('style'); | |
| css.appendChild( | |
| document.createTextNode( | |
| '*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}', | |
| ), | |
| ); | |
| document.head.appendChild(css); | |
| return () => { | |
| // Force restyle | |
| (() => window.getComputedStyle(document.body))(); | |
| // Wait for next tick before removing | |
| setTimeout(() => { | |
| document.head.removeChild(css); | |
| }, 1); | |
| }; | |
| }; | |
| const getSystemTheme = (e?: MediaQueryList | MediaQueryListEvent) => { | |
| const event = e ?? window.matchMedia(MEDIA); | |
| const isDark = event.matches; | |
| const systemTheme = isDark ? 'dark' : 'light'; | |
| return systemTheme; | |
| }; | |
| /* | |
| This file is adapted from next-themes to work with tanstack start. | |
| next-themes can be found at https://github.com/pacocoursey/next-themes under the MIT license. | |
| */ | |
| // biome-ignore lint/suspicious/noExplicitAny: <explanation> | |
| export const script: (...args: any[]) => void = ( | |
| attribute, | |
| storageKey, | |
| defaultTheme, | |
| forcedTheme, | |
| themes, | |
| value, | |
| enableSystem, | |
| enableColorScheme, | |
| ) => { | |
| const el = document.documentElement; | |
| const systemThemes = ['light', 'dark']; | |
| const isClass = attribute === 'class'; | |
| const classes = isClass && value ? themes.map((t: string | number) => value[t] || t) : themes; | |
| function updateDOM(theme: string) { | |
| if (isClass) { | |
| el.classList.remove(...classes); | |
| el.classList.add(theme); | |
| } else { | |
| el.setAttribute(attribute, theme); | |
| } | |
| setColorScheme(theme); | |
| } | |
| function setColorScheme(theme: string) { | |
| if (enableColorScheme && systemThemes.includes(theme)) { | |
| el.style.colorScheme = theme; | |
| } | |
| } | |
| function getSystemTheme() { | |
| return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; | |
| } | |
| if (forcedTheme) { | |
| updateDOM(forcedTheme); | |
| } else { | |
| try { | |
| const themeName = localStorage.getItem(storageKey) || defaultTheme; | |
| const isSystem = enableSystem && themeName === 'system'; | |
| const theme = isSystem ? getSystemTheme() : themeName; | |
| updateDOM(theme); | |
| } catch (e) { | |
| // | |
| } | |
| } | |
| }; |
This is amazing. Posted 3 days before I needed it 🙏🏼
@samuelhorn i updated the gist, there was a strange bug breaking streaming when using deferred loader and useSupenseQuery. this seems to have fixed it
Awesome, thanks! 🙏🏼
For what it's worth - made a simple starter with this theme switcher, Shadcn and Tailwindcss, if you ever find the use for such a thing 😊
Amazing job!
Thank you sir!!
Im getting an error with this @WellDone2094
Cannot render a sync or defer <script> outside the main document without knowing its order. Try adding async="" or moving it into the root <head> tag. Theme | @ | theme-provider.tsx:210
Im getting an error with this @WellDone2094
Cannot render a sync or defer <script> outside the main document without knowing its order. Try adding async="" or moving it into the root <head> tag. Theme | @ | theme-provider.tsx:210
i'm not sure what's causing your issue. i would need to see the code to be able to give you any help
All that code just to get a theme switcher to work in Tanstack Start. I really hope they find a way to simplify it and bake it natively into Tanstack
@JosephTico you probably need to move your inside the tag
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body>
<ThemeProvider attribute="class">
{children}
<TanStackRouterDevtools position="bottom-right" />
<Scripts />
</ThemeProvider>
</body>
</html>
);
}make sure it's inside <RootDocument> not <RootComponent>
Hey thanks for the gist,
What's the purpose of https://gist.github.com/WellDone2094/16107a2a9476b28a5b394bee3fa1b8a3#file-themeprovider-tsx-L157
This variable is unused
Hey @WellDone2094, I published a nnpm package based on this gist.
I made it for myself but might be useful for others who want a quick fix.
Instructions:
npm install tanstack-theme-kit
<html suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
<Scripts />
</body>
</html>
Thank you for your work on this.
To anyone using this gits, i'm currently not using tanstak start anymore and i don't have time to maintain this gist.
Thanks @augiwan for creating an npm package
usage example: