starter-themes
v0.0.2
Published
Theme management for TanStack Start apps. System preference, no flash, cross-tab sync.
Maintainers
Readme
starter-themes
Theme management for TanStack Start apps. Light, dark, and system themes with no flash of the wrong theme, system-preference tracking, and cross-tab sync.
- No FOUC — a blocking script (via TanStack Router's
ScriptOnce) applies the theme class before the body paints. - System theme — follows
prefers-color-schemeand reacts to OS changes live. - Cross-tab sync — switching the theme in one tab updates every open tab.
- SSR-safe — built on TanStack Start's
createIsomorphicFn/createClientOnlyFn, notypeof windowchecks. - Familiar API —
ThemeProvider+useTheme, withforcedTheme,storageKey,disableTransitionOnChange, and CSPnoncesupport (next-themes-compatible surface). - Zero dependencies beyond your existing TanStack Start setup.
Installation
npm install starter-themes
# or
pnpm add starter-themesRequires @tanstack/react-start, @tanstack/react-router, and React 18 or 19 as peers.
Quick start
Wrap your root route with ThemeProvider (inside <html>, around your app):
// src/routes/__root.tsx
import {
HeadContent,
Outlet,
Scripts,
createRootRoute,
} from '@tanstack/react-router'
import { ThemeProvider } from 'starter-themes'
export const Route = createRootRoute({
component: RootComponent,
})
function RootComponent() {
return (
<html suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body>
<ThemeProvider>
<Outlet />
</ThemeProvider>
<Scripts />
</body>
</html>
)
}The provider adds a light or dark class to <html> (and sets style.colorScheme), so it works out of the box with Tailwind's dark: variant (@custom-variant dark (&:is(.dark *)); in Tailwind v4, or darkMode: 'class' in v3).
Toggling the theme
import { useTheme } from 'starter-themes'
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme()
return (
<button
aria-label="Toggle theme"
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
>
{/* Render both icons and swap with CSS to avoid hydration mismatches */}
<SunIcon className="block dark:hidden" />
<MoonIcon className="hidden dark:block" />
</button>
)
}Hydration tip: the server renders with
defaultTheme, so avoid branching JSX ontheme/resolvedThemeduring the first render. Swap visuals with thedark:CSS variant (as above), or gate theme-dependent markup behind a mounted check.
API
<ThemeProvider />
| Prop | Type | Default | Description |
| --------------------------- | ------------------------------- | ---------- | ------------------------------------------------------------------------ |
| defaultTheme | 'light' \| 'dark' \| 'system' | 'system' | Theme used when nothing is stored. |
| storageKey | string | 'theme' | localStorage key for persistence. |
| enableColorScheme | boolean | true | Sets style.colorScheme on <html> (native form controls, scrollbars). |
| disableTransitionOnChange | boolean | true | Suppresses CSS transitions while switching themes. |
| forcedTheme | 'light' \| 'dark' \| 'system' | — | Force a theme (e.g. per-page); setTheme becomes a no-op. |
| nonce | string | — | CSP nonce for injected style/script tags. |
useTheme()
Returns:
| Field | Type | Description |
| --------------- | ------------------------------- | ------------------------------------------------------ |
| theme | 'light' \| 'dark' \| 'system' | Current theme mode. |
| resolvedTheme | 'light' \| 'dark' | theme with 'system' resolved to the OS preference. |
| systemTheme | 'light' \| 'dark' | Current OS preference. |
| setTheme | (theme: ThemeMode) => void | Set and persist the theme. |
| forcedTheme | ThemeMode \| undefined | The forced theme, if any. |
