Perfect dark mode for your TanStack Start app in 2 lines of code.
The goal of this library is to get dark mode to work out of the box with TanStack Start and other React frameworks. I've made it for myself, but others are welcome to use.
This library is an adaptation of ThemeProvider which itself is a modified version of next-themes. All credits to pacocoursey for next-themes and WellDone2094 for the adaptation.
I've also kept this README as close as possible to next-themes to help new developers.
- âś… Perfect dark mode in 2 lines of code
- âś… System setting with
prefers-color-scheme - âś… Themed browser UI with
color-scheme - âś… Works with TanStack Start SSR
- âś… No flash on load (SSR compatible)
- âś… Sync theme across tabs and windows
- âś… Disable transitions when changing themes
- âś… Force specific themes on pages/routes
- âś… Class or data attribute selector
- âś…
useThemehook for theme control - âś… Framework-agnostic (works with any React framework)
npm install tanstack-theme-kit
# or
yarn add tanstack-theme-kit
# or
pnpm add tanstack-theme-kitIn your root layout or app entry point (typically app/root.tsx or app/routes/__root.tsx), wrap your application with ThemeProvider:
// app/routes/__root.tsx
import { ThemeProvider } from 'tanstack-theme-kit'
import { Outlet } from '@tanstack/react-router'
export default function Root() {
return (
<html suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body>
<ThemeProvider>
<Outlet />
</ThemeProvider>
</body>
</html>
)
}That's it! Your TanStack Start app now fully supports dark mode.
Note! Add suppressHydrationWarning to your
<html>element to prevent warnings. The ThemeProvider injects a script that updates the html element before React hydrates, which is intentional to prevent flashing.
For other React frameworks, wrap your app at the highest level possible:
import { ThemeProvider } from 'tanstack-theme-kit'
function App() {
return (
<ThemeProvider>
{/* Your app content */}
</ThemeProvider>
)
}Your app now fully supports dark mode, including System preference with prefers-color-scheme. The theme is also immediately synced between tabs. By default, tanstack-theme-kit modifies the data-theme attribute on the html element, which you can easily use to style your app:
:root {
/* Your default theme */
--background: white;
--foreground: black;
}
[data-theme='dark'] {
--background: black;
--foreground: white;
}Note! If you set the
attributeprop toclass(for Tailwind), the library will modify theclassattribute on thehtmlelement instead. See With TailwindCSS.
Your UI will need to know the current theme and be able to change it. The useTheme hook provides theme information:
import { useTheme } from 'tanstack-theme-kit'
const ThemeChanger = () => {
const { theme, setTheme } = useTheme()
return (
<div>
The current theme is: {theme}
<button onClick={() => setTheme('light')}>Light Mode</button>
<button onClick={() => setTheme('dark')}>Dark Mode</button>
</div>
)
}Warning! The above code is hydration unsafe and will throw a hydration mismatch warning when rendering with SSR. This is because we cannot know the
themeon the server, so it will always beundefineduntil mounted on the client.You should delay rendering any theme toggling UI until mounted on the client. See the example.
Let's dig into the details.
All your theme configuration is passed to ThemeProvider.
storageKey = 'theme': Key used to store theme setting in localStoragedefaultTheme = 'system': Default theme name (for v0.0.12 and lower the default waslight). IfenableSystemis false, the default theme islightforcedTheme: Forced theme name for the current page (does not modify saved theme settings)enableSystem = true: Whether to switch betweendarkandlightbased onprefers-color-schemeenableColorScheme = true: Whether to indicate to browsers which color scheme is used (dark or light) for built-in UI like inputs and buttonsdisableTransitionOnChange = false: Optionally disable all CSS transitions when switching themes (example)themes = ['light', 'dark']: List of theme namesattribute = 'data-theme': HTML attribute modified based on the active theme- accepts
classanddata-*(meaning any data attribute,data-mode,data-color, etc.) (example)
- accepts
value: Optional mapping of theme name to attribute value- value is an
objectwhere key is the theme name and value is the attribute value (example)
- value is an
nonce: Optional nonce passed to the injectedscripttag, used to allow-list the script in your CSP
useTheme takes no parameters, but returns:
theme: Active theme namesetTheme(name): Function to update the theme. The API is identical to the set function returned byuseStatehook. Pass the new theme value or use a callback to set the new theme based on the current theme.forcedTheme: Forced page theme or falsy. IfforcedThemeis set, you should disable any theme switching UIsystemTheme: IfenableSystemis true, represents the System theme preference ("dark" or "light"), regardless of what the active theme isthemes: The list of themes passed toThemeProvider(with "system" appended, ifenableSystemis true)
Not too bad, right? Let's see how to use these properties with examples:
The defaultTheme is automatically set to "system", so to use System preference you can simply use:
<ThemeProvider>
{children}
</ThemeProvider>If you don't want a System theme, disable it via enableSystem:
<ThemeProvider enableSystem={false}>
{children}
</ThemeProvider>If your app uses a class to style the page based on the theme (e.g., with Tailwind), change the attribute prop to class:
<ThemeProvider attribute="class">
{children}
</ThemeProvider>Now, setting the theme to "dark" will set class="dark" on the html element.
Let's say your marketing page should always be dark mode. You can force a theme by passing the forcedTheme prop to your ThemeProvider. In TanStack Start, you might do this based on the current route:
// app/routes/__root.tsx
import { ThemeProvider } from 'tanstack-theme-kit'
import { Outlet, useRouter } from '@tanstack/react-router'
export default function Root() {
const router = useRouter()
const isDarkPage = router.state.location.pathname === '/marketing'
return (
<html suppressHydrationWarning>
<body>
<ThemeProvider forcedTheme={isDarkPage ? 'dark' : undefined}>
<Outlet />
</ThemeProvider>
</body>
</html>
)
}Done! When on the /marketing route, the theme is always dark (regardless of user preference), and calling setTheme from useTheme is now a no-op. You should disable any theme switching UI when a theme is forced:
const { forcedTheme } = useTheme()
// Theme is forced, we shouldn't allow user to change the theme
const disabled = !!forcedThemeWe can forcefully disable all CSS transitions before the theme is changed, and re-enable them immediately afterwards. This ensures your UI with different transition durations won't feel inconsistent when changing the theme.
To enable this behavior, pass the disableTransitionOnChange prop:
<ThemeProvider disableTransitionOnChange>
{children}
</ThemeProvider>The name of the active theme is used as both the localStorage value and the value of the DOM attribute. If the theme name is "pink", localStorage will contain theme=pink and the DOM will be data-theme="pink". You cannot modify the localStorage value, but you can modify the DOM value.
If we want the DOM to instead render data-theme="my-pink-theme" when the theme is "pink", pass the value prop:
<ThemeProvider value={{ pink: 'my-pink-theme' }}>
{children}
</ThemeProvider>Done! To be extra clear, this affects only the DOM. Here's how all the values will look:
const { theme } = useTheme()
// => "pink"
localStorage.getItem('theme')
// => "pink"
document.documentElement.getAttribute('data-theme')
// => "my-pink-theme"tanstack-theme-kit is designed to support any number of themes! Simply pass a list of themes:
<ThemeProvider themes={['pink', 'red', 'blue']}>
{children}
</ThemeProvider>Note! When you pass
themes, the default set of themes ("light" and "dark") are overridden. Make sure you include those if you still want your light and dark themes:
<ThemeProvider themes={['pink', 'red', 'blue', 'light', 'dark']}>
{children}
</ThemeProvider>This library does not rely on your theme styling using CSS variables. You can hard-code the values in your CSS, and everything will work as expected (without any flashing):
html,
body {
color: #000;
background: #fff;
}
[data-theme='dark'],
[data-theme='dark'] body {
color: #fff;
background: #000;
}tanstack-theme-kit is completely CSS independent and works with any styling library. For example, with Styled Components:
import { createGlobalStyle } from 'styled-components'
import { ThemeProvider } from 'tanstack-theme-kit'
// Your theming variables
const GlobalStyle = createGlobalStyle`
:root {
--fg: #000;
--bg: #fff;
}
[data-theme="dark"] {
--fg: #fff;
--bg: #000;
}
`
function App() {
return (
<>
<GlobalStyle />
<ThemeProvider>
{/* Your app content */}
</ThemeProvider>
</>
)
}Because we cannot know the theme on the server, many of the values returned from useTheme will be undefined until mounted on the client. This means if you try to render UI based on the current theme before mounting on the client, you will see a hydration mismatch error.
The following code sample is unsafe:
import { useTheme } from 'tanstack-theme-kit'
// Do NOT use this! It will throw a hydration mismatch error.
const ThemeSwitch = () => {
const { theme, setTheme } = useTheme()
return (
<select value={theme} onChange={e => setTheme(e.target.value)}>
<option value="system">System</option>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
)
}
export default ThemeSwitchTo fix this, make sure you only render UI that uses the current theme when the page is mounted on the client:
import { useState, useEffect } from 'react'
import { useTheme } from 'tanstack-theme-kit'
const ThemeSwitch = () => {
const [mounted, setMounted] = useState(false)
const { theme, setTheme } = useTheme()
// useEffect only runs on the client, so now we can safely show the UI
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return null
}
return (
<select value={theme} onChange={e => setTheme(e.target.value)}>
<option value="system">System</option>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
)
}
export default ThemeSwitchAlternatively, you could lazy load the component on the client side using React.lazy:
import { lazy, Suspense } from 'react'
const ThemeSwitch = lazy(() => import('./ThemeSwitch'))
const ThemePage = () => {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<ThemeSwitch />
</Suspense>
</div>
)
}
export default ThemePageTo avoid Layout Shift, consider rendering a skeleton/placeholder until mounted on the client side.
You can also use CSS to hide or show content based on the current theme. To avoid the hydration mismatch, you'll need to render both versions of the UI, with CSS hiding the unused version. For example:
function ThemedImage() {
return (
<>
{/* When the theme is dark, hide this div */}
<div data-hide-on-theme="dark">
<img src="light.png" alt="Light theme" />
</div>
{/* When the theme is light, hide this div */}
<div data-hide-on-theme="light">
<img src="dark.png" alt="Dark theme" />
</div>
</>
)
}
export default ThemedImage[data-theme='dark'] [data-hide-on-theme='dark'],
[data-theme='light'] [data-hide-on-theme='light'] {
display: none;
}NOTE! Tailwind only supports dark mode in version >2.
In your tailwind.config.js, set the dark mode property to selector:
// tailwind.config.js
module.exports = {
darkMode: 'selector'
}Note: If you are using an older version of tailwindcss < 3.4.1 use 'class' instead of 'selector'
Set the attribute prop for your ThemeProvider to class:
<ThemeProvider attribute="class">
{children}
</ThemeProvider>If you're using the value prop to specify different attribute values, make sure your dark theme explicitly uses the "dark" value, as required by Tailwind.
That's it! Now you can use dark-mode specific classes:
<h1 className="text-black dark:text-white">My Heading</h1>Tailwind also allows you to use a custom selector for dark-mode as of v3.4.1.
In that case, your tailwind.config.js would look like this:
// tailwind.config.js
module.exports = {
// data-mode is used as an example
darkMode: ['selector', '[data-mode="dark"]']
}Now set the attribute for your ThemeProvider to data-mode:
<ThemeProvider attribute="data-mode">
{children}
</ThemeProvider>With this setup, you can now use Tailwind's dark mode classes, as in the previous example.
ThemeProvider automatically injects a script to update the html element with the correct attributes before the rest of your page loads. This means the page will not flash under any circumstances, including forced themes, system theme, multiple themes, and incognito. No noflash.js required.
Why is my page still flashing?
In development mode, the page may still flash. When you build your app in production mode, there will be no flashing.
Why do I get server/client mismatch error?
When using useTheme, you will see a hydration mismatch error when rendering UI that relies on the current theme. This is because many of the values returned by useTheme are undefined on the server, since we can't read localStorage until mounting on the client. See the example for how to fix this error.
Do I need to use CSS variables with this library?
Nope. See the example.
Can I set the class or data attribute on the body or another element?
No, the library only supports setting attributes on the html element. If you have a good reason for supporting this feature, please open an issue.
Is the injected script minified?
Yes.
How do I know what the system theme is?
Use systemTheme from the useTheme hook. When enableSystem is true, this returns either "dark" or "light" based on the system preference, regardless of what the active theme is.
const { theme, systemTheme } = useTheme()
// theme might be "system", "dark", or "light"
// systemTheme is either "dark" or "light" (the actual system preference)This library is adapted from next-themes by Paco Coursey. The original library was designed specifically for Next.js, and this version has been modified to work seamlessly with TanStack Start and other React frameworks by removing Next.js-specific dependencies.
MIT