@teispace/next-themes
v3.0.2
Published
Feature-rich, lightweight theme management for Next.js and React. Zero-flash SSR, hybrid storage, view transitions, typed themes.
Maintainers
Readme
@teispace/next-themes
Feature-rich, lightweight, production-grade theme management for Next.js and React. Hybrid cookie + localStorage storage, typed themes, View Transitions, scoped sub-trees, Tailwind v4 preset, zero-FOUC SSR, codemod from
next-themes.
yarn add @teispace/next-themesContents
- Why another theme library?
- Install
- Quick start
- Core concepts
- Server-side rendering
- Typed factory —
createThemes - Hooks
- Components
- View transitions
- Tailwind
- Storage adapters (advanced)
- Upgrading from 2.x
- Migration from
next-themes - Full API reference
- Recipes
- Troubleshooting / FAQ
- Performance & bundle sizes
- Browser support
- Examples app
- License
Why another theme library?
next-themes by paco is battle-tested, but in 2026 it has unresolved React 19 bugs, no first-class server-side theme reading, and broken multi-class cleanup. Newer rewrites like @wrksz/themes closed some of those gaps but still ship a god-component provider, an inline script that re-inserts a transition-disable <style> on every apply (a real flicker source), and a fixed-storage model with no plugin path.
@teispace/next-themes is engineered for production from the inside out:
- Hybrid storage — cookie is authoritative for SSR (zero flash) while
localStoragemirrors for cross-tab sync. Five modes:hybrid,cookie,local,session,none. useSyncExternalStore+ external store — state, DOM apply, transitions, and subscriptions all live outside React. State survives React 19Activity/cacheComponents/ suspension without going stale.useServerInsertedHTMLscript injection — avoids the React 19 inline-script client warning and places the blocker where it actually blocks.- No-op short-circuit —
setTheme(currentTheme)is a true no-op: no DOM write, no<style>injection, no flicker. (Common bug elsewhere — re-applying the same theme retriggers transition suppression.) - XSS-hardened inline script — escapes
</script>, U+2028, U+2029 in every user-input path (themeColor,value,forcedTheme) so a hand-crafted prop value can't break out of the inline<script>tag. - Bundler-proof script body — the inline script is a raw string template, never serialized via
Function.toString(), so esbuild/swc__name(...)wrappers and identifier renames can never crash it at runtime. - Per-instance stores — nested
<ThemeProvider>s are genuinely independent. No hidden global state. - Pluggable storage adapters — public
StorageAdapterinterface (get/set/ optionalsubscribe). Bring your own backend. - Sync
getTheme(Request)for middleware — read the theme from aRequestsynchronously in middleware and edge functions, noawaitrequired. Sec-CH-Prefers-Color-Schemeclient hint — zero-flash SSR even for first-time visitors without a cookie.- View Transitions — opt into cursor-origin circular reveals or fade animations with one prop. Respects
prefers-reduced-motion. createThemes<T>()— full literal-type inference for your theme list across provider, hooks, and components.- Eight subpath entries —
.,/client,/server,/adapters,/script,/tailwind,/tailwind.css,/codemod/from-next-themes. You pay only for what you import. - Codemod from
next-themes— onenpx jscodeshiftcommand rewrites your imports.
Install
yarn add @teispace/next-themes
# or
npm install @teispace/next-themes
# or
pnpm add @teispace/next-themes
# or
bun add @teispace/next-themesPeer dependencies: react ≥ 18, react-dom ≥ 18, next ≥ 13 (optional — only if you use the default Next entry).
Quick start
Next.js App Router
Three files: a provider boundary, a server-side seed, and your app code.
// app/layout.tsx — STAYS A SERVER COMPONENT
import { ThemeProvider } from '@teispace/next-themes';
import { getTheme } from '@teispace/next-themes/server';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const initialTheme = await getTheme();
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" initialTheme={initialTheme ?? undefined}>
{children}
</ThemeProvider>
</body>
</html>
);
}No 'use client' required in your code. <ThemeProvider> ships its own client boundary, so you can drop it into a server component (including app/layout.tsx) and Next.js handles the boundary automatically. Your <html>, <body>, and any siblings remain server-rendered.
// any client component
'use client';
import { useTheme } from '@teispace/next-themes';
export function Toggle() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Toggle
</button>
);
}That's it. The anti-FOUC script is injected server-side via useServerInsertedHTML, the theme is read from the cookie on the server, and the client picks up without mismatch.
Zero-flicker setup (recommended for production)
useServerInsertedHTML places the script wherever React reaches its first flush boundary — usually inside <body>. With streaming, partial prerendering, or cacheComponents, the body's first bytes can paint before the script lands, briefly showing the wrong theme. To eliminate every possible flicker source, render the script directly in <head> and tell the provider to skip its own injection:
// app/layout.tsx — still a server component
import { ThemeProvider } from '@teispace/next-themes';
import { getTheme, getThemeScript } from '@teispace/next-themes/server';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const initialTheme = await getTheme();
const themeScript = getThemeScript({
attribute: 'class',
initialTheme: initialTheme ?? undefined,
});
return (
<html lang="en" suppressHydrationWarning>
<head>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: anti-FOUC */}
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body>
<ThemeProvider attribute="class" initialTheme={initialTheme ?? undefined} noScript>
{children}
</ThemeProvider>
</body>
</html>
);
}This guarantees the inline script runs before the browser paints any body pixels, regardless of how Next.js streams the response. Pass the same options to getThemeScript() and <ThemeProvider> so they stay in sync — or use createThemes() so the configuration lives in one place.
Vite / Remix / Generic React
// App.tsx
import { ThemeProvider, useTheme } from '@teispace/next-themes/client';
export function App() {
return (
<ThemeProvider attribute="class">
<Inner />
</ThemeProvider>
);
}
function Inner() {
const { theme, setTheme } = useTheme();
return <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>Toggle</button>;
}The /client entry uses an inline <script suppressHydrationWarning> instead of useServerInsertedHTML. API is otherwise identical.
Core concepts
Attributes and selectors
The provider applies the active theme to the target element (default <html>) as an HTML attribute. You control which attribute with the attribute prop:
<ThemeProvider attribute="class"> // <html class="dark">
<ThemeProvider attribute="data-theme"> // <html data-theme="dark">
<ThemeProvider attribute={['class', 'data-theme']}> // bothYour CSS then scopes styles to that attribute:
/* class attribute */
.dark { --bg: #0f1115; }
/* data-theme attribute */
[data-theme="dark"] { --bg: #0f1115; }If your CSS expects a different value than your logical theme name, use value:
<ThemeProvider
themes={['light', 'dark', 'solarized']}
attribute="class"
value={{
dark: 'theme-dark',
solarized: 'theme-solarized high-contrast', // multi-class works
}}
>The library cleans up stale classes on every switch — including every value in the value map — so moving between multi-class themes never leaves leftovers.
Storage modes
Pick where the user's choice persists:
| Mode | Server-readable | Cross-tab sync | When to use |
|---|:-:|:-:|---|
| hybrid (default) | ✅ | ✅ | Zero-flash SSR + multi-tab apps. |
| cookie | ✅ | — | SSR apps where cross-tab doesn't matter. |
| local | — | ✅ | SPAs with no server rendering. |
| session | — | — | Per-tab, forgotten on close. |
| none | — | — | forcedTheme or in-memory only. |
<ThemeProvider storage="hybrid" storageKey="theme" />Hybrid semantics. Cookie is authoritative on read (so the server and the inline script read the same value), while localStorage mirrors for cross-tab storage events. Writes go to both.
The resolved theme
There are two theme values:
theme— the selected theme, which may be'system'.resolvedTheme— the concrete theme after system resolution (always one of your real themes).
const { theme, resolvedTheme } = useTheme();
// theme: 'system'
// resolvedTheme: 'dark' (user's OS preference)Use resolvedTheme for rendering decisions (CSS, assets), and theme for toggle UI state.
The inline anti-FOUC script
On the server, the provider emits a tiny, synchronous, blocking <script> inside <head> (via useServerInsertedHTML on Next, via React's dangerouslySetInnerHTML with suppressHydrationWarning elsewhere). That script:
- Reads the theme from your configured storage (cookie first, then localStorage, then
initialTheme, thendefaultTheme). - Resolves
'system'viamatchMedia('(prefers-color-scheme: dark)'). - Applies the correct attribute + class +
color-scheme+<meta name="theme-color">to the target element. - Optionally freezes CSS transitions for one frame.
It runs before React hydrates, before first paint, so the correct theme is visible in the first rendered frame — no flash.
The script accepts a nonce for CSP environments and always wraps its body in a try/catch so it is silent on any runtime error (never blocks rendering).
Server-side rendering
Reading the theme server-side
import { getTheme } from '@teispace/next-themes/server';
export default async function RootLayout({ children }) {
const initialTheme = await getTheme();
// Preference order: theme cookie → Sec-CH-Prefers-Color-Scheme hint → null
return (
<html suppressHydrationWarning>
<body>
<ThemeProvider initialTheme={initialTheme ?? undefined}>
{children}
</ThemeProvider>
</body>
</html>
);
}With Next.js 16 Cache Components (cacheComponents: true)
The snippet above will not build with cacheComponents: true. Cache Components
makes reading cookies() outside a cache boundary a build error, and that
includes a getTheme() call at the top level of a layout — getTheme() reads
the theme cookie.
This is not a problem for zero-flash rendering. The inline anti-FOUC script is
what prevents the flash, and it needs no request data at all: it reads the
cookie in the browser before first paint. initialTheme is only there to seed
the React tree so theme-dependent components (<ThemedImage>, <ThemedIcon>,
anything branching on useTheme()) render the right thing during SSR.
So you have three options, in order of preference:
1. Drop initialTheme from the layout. The script still eliminates the
flash. Do this unless you render theme-dependent markup on the server.
export default function RootLayout({ children }) {
return (
<html suppressHydrationWarning>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}2. Read the cookie inside a 'use cache: private' boundary. That directive
is allowed to call cookies(), and its result is cached per-browser and never
on the server.
async function ThemeShell({ children }: { children: React.ReactNode }) {
'use cache: private';
const initialTheme = await getTheme();
return <ThemeProvider initialTheme={initialTheme ?? undefined}>{children}</ThemeProvider>;
}3. Opt the segment out with export const instant = false if you want the
old request-time behaviour for that route.
Do not let a per-user value reach the inline script. The script is emitted through
useServerInsertedHTML, so it becomes part of the prerendered static shell. Passing a request-derivedinitialThemeorforcedThemebakes one user's theme into a shell served to everyone. Seed the React tree (option 2) rather than the script.
Reading the theme in middleware (sync, from Request)
getTheme() has a sync overload that reads directly from a Request object — no await, no next/headers. Use this in middleware, edge functions, route rewriters, or anywhere you have a Request but no Next.js async context.
// proxy.ts (Next.js 16 renamed middleware.ts -> proxy.ts, and the exported
// function from `middleware` to `proxy`. On Next 15 and earlier use
// middleware.ts / export function middleware.)
import { NextResponse, type NextRequest } from 'next/server';
import { getTheme } from '@teispace/next-themes/server';
export function proxy(request: NextRequest) {
const theme = getTheme(request, { defaultTheme: 'system' });
// Branch on the theme: rewrite, set a header, redirect, etc.
const res = NextResponse.next();
res.headers.set('x-theme', theme ?? 'system');
return res;
}The sync overload reads the cookie and the Sec-CH-Prefers-Color-Scheme hint from request.headers, with the same precedence as the async path. Pass a themes whitelist if you want unknown cookie values treated as missing:
const theme = getTheme(request, { themes: ['light', 'dark'], defaultTheme: 'light' });Pass the whitelist as const and the return type narrows to it, so server
code can branch exhaustively without re-narrowing:
const theme = getTheme(request, { themes: ['light', 'dark'] as const });
// ^? 'light' | 'dark' | 'system' | null
// Without `as const` (or without `themes`) you get the wider `string | null`.'system' is always included — it is a legitimate stored value that the
provider resolves — and null means neither the cookie nor the client hint
supplied anything.
When to use which overload
await getTheme()— Server Components, Route Handlers (usescookies()/headers()fromnext/headers).getTheme(request)— proxy/middleware, edge functions, custom servers (works on the rawRequest, no Next.js binding).
Prefers-color-scheme client hint
Chromium browsers can send Sec-CH-Prefers-Color-Scheme: light|dark on each request — but only after the server asks for it via Accept-CH. With this hint, a first-time visitor with no cookie still gets zero-flash SSR matching their OS preference.
Coverage.
Sec-CH-Prefers-Color-SchemeandAccept-CHare Chromium-only — Chrome, Edge and Chrome for Android 93+. Firefox and Safari do not send the hint and there is no sign they will. Treat this as a progressive enhancement for the majority of traffic, not a general zero-flash solution: the inline script is what guarantees no flash everywhere else.
// proxy.ts (middleware.ts on Next 15 and earlier)
import { NextResponse } from 'next/server';
import { acceptClientHintsHeader } from '@teispace/next-themes/server';
export function proxy() {
const res = NextResponse.next();
res.headers.set('Accept-CH', acceptClientHintsHeader());
// → 'Accept-CH: Sec-CH-Prefers-Color-Scheme'
return res;
}After this runs once, subsequent navigations include the hint and getTheme() picks it up automatically.
Writing the cookie from server actions
When you persist the theme from the server (e.g. on a user-preferences save):
'use server';
import { writeThemeCookie } from '@teispace/next-themes/server';
export async function saveTheme(theme: string) {
await writeThemeCookie(theme);
// ...persist to your user profile here
}Or, for custom servers / edge workers, build the header yourself:
import { setThemeCookie } from '@teispace/next-themes/server';
response.headers.set('Set-Cookie', setThemeCookie('dark', {
maxAge: 60 * 60 * 24 * 365,
secure: true,
domain: '.example.com',
}));Typed factory — createThemes
For maximum type safety, create a typed theme module once and import from it everywhere.
// app/theme.ts
'use client';
import { createThemes } from '@teispace/next-themes';
export const {
ThemeProvider,
useTheme,
useThemeValue,
useThemeEffect,
ThemedImage,
ThemedIcon,
ScopedTheme,
} = createThemes({
themes: ['light', 'dark', 'sepia', 'mint'] as const, // ← literal tuple
defaultTheme: 'system',
attribute: 'class',
storage: 'hybrid',
// any ThemeProvider prop works as a default:
disableTransitionOnChange: true,
themeColor: { light: '#fff', dark: '#0f1115' },
});Now every consumer gets literal types:
import { useTheme, useThemeValue, ScopedTheme } from '@/app/theme';
const { theme, setTheme } = useTheme();
// theme: 'light' | 'dark' | 'sepia' | 'mint' | 'system'
// setTheme(value) — value is typed to the union above
const accent = useThemeValue({
light: '#2563eb',
dark: '#60a5fa',
sepia: '#b45309',
mint: '#0f766e',
// unknown keys are a compile error
});
<ScopedTheme theme="sepia">...</ScopedTheme>
// ScopedTheme.theme is typed tooPer-use props on the returned ThemeProvider override the factory defaults, so you can still customize per-mount (e.g. set a different defaultTheme on an auth page).
When to use the factory vs. the generic hook. The factory gives perfect inference from a single config. The generic useTheme<T>() takes a type parameter per-call and is handy for quick prototyping. Use the factory for shipped code.
Each factory owns its own context
createThemes() mints a private React context and binds its provider, hooks
and components to it. Two independently-created theme APIs therefore never see
each other's store, and can be nested:
// app/theme.ts — the app shell
export const Shell = createThemes({ themes: ['light', 'dark'] as const });
// widgets/embed-theme.ts — an embedded widget with its own palette
export const Widget = createThemes({
themes: ['alpha', 'beta'] as const,
attribute: 'data-widget-theme', // a distinct attribute avoids DOM contention
});<Shell.ThemeProvider>
<Header /> {/* Shell.useTheme() → 'light' | 'dark' */}
<Widget.ThemeProvider>
<Embed /> {/* Widget.useTheme() → 'alpha' | 'beta' */}
</Widget.ThemeProvider>
</Shell.ThemeProvider>A factory hook used outside its own provider stays inert (theme: '',
themes: []) rather than silently falling through to a neighbouring API's
store — so themes.length === 0 remains a reliable "not mounted yet" check.
Changed in 3.0. Previously every hook and component read one module-level context, so two
createThemes()APIs collided: whichever provider sat nearest in the tree served both hook sets, and an embedded widget could report the shell's themes. See Upgrading from 2.x.
Hooks
useTheme
const {
theme, // string — the selected value (may be 'system')
resolvedTheme, // string — the concrete theme after system resolution
systemTheme, // 'light' | 'dark' | null
forcedTheme, // string | null — echoes the forcedTheme prop
themes, // string[] — list of available themes (includes 'system' when enableSystem)
setTheme, // (theme, options?) => void
} = useTheme();Typing:
// generic form (no factory)
const { theme, setTheme } = useTheme<'light' | 'dark' | 'sepia'>();useThemeValue
Map the active theme to a value. Resolution order: resolvedTheme → raw theme → map.default.
import { useThemeValue } from '@teispace/next-themes';
const accent = useThemeValue({ light: '#2563eb', dark: '#60a5fa', default: '#000' });
const copy = useThemeValue({ light: 'Day mode', dark: 'Night mode' });Returns undefined when no key matches and no default is provided.
useThemeEffect
Like useEffect, but skips the first render and only fires on theme changes. Perfect for analytics, server-sync, one-shot side effects.
import { useThemeEffect } from '@teispace/next-themes';
function Analytics() {
useThemeEffect((theme, resolvedTheme) => {
track('theme_changed', { theme, resolvedTheme });
return () => abortPending(); // optional cleanup on next change
});
return null;
}Deps work the same as useEffect:
useThemeEffect((theme) => { /* ... */ }, [user.id]);Components
<ThemedImage />
Swap <img src> based on the active theme. Reads from the external store directly, so when the provider is seeded with initialTheme from the server cookie, the very first render (server and client) picks the correct variant — no mount flag, no post-hydration swap, no flash.
import { ThemedImage } from '@teispace/next-themes';
<ThemedImage
sources={{ light: '/logo-light.svg', dark: '/logo-dark.svg' }}
fallbackSrc="/logo-light.svg"
alt="Brand logo"
width={240}
height={100}
className="rounded-md"
/>If the resolved theme could legitimately differ between server and client (e.g. theme="system" with no client hint), wrap the usage in a <span suppressHydrationWarning> or use the CSS pattern below.
For pixel-perfect SSR switching driven entirely by CSS, use theme-scoped display:
html[data-theme="dark"] .logo-light { display: none; }
html[data-theme="dark"] .logo-dark { display: block; }<ThemedIcon />
Same idea for arbitrary React nodes — SVG icons, logos, anything.
import { ThemedIcon } from '@teispace/next-themes';
<ThemedIcon
variants={{ light: <SunIcon />, dark: <MoonIcon /> }}
fallback={<SunIcon />}
/><ScopedTheme />
Force a sub-tree to a specific theme without touching the page root. Inside the scope, useTheme() reports the scoped theme and setTheme is a no-op.
import { ScopedTheme } from '@teispace/next-themes';
<ScopedTheme theme="dark">
<Modal /> {/* always dark, regardless of page theme */}
</ScopedTheme>
// render as any element, with any attribute strategy
<ScopedTheme theme="sepia" as="section" attribute="data-theme" className="preview">
<Preview />
</ScopedTheme>
// map theme names to CSS class/attribute values
<ScopedTheme theme="dark" value={{ dark: 'theme-dark' }}>...</ScopedTheme>Props: theme, children, as? (default 'div'), attribute? (default 'class'), value?, plus any DOM props passed through to the wrapper element.
View transitions
Animate theme changes with the View Transitions API. Gracefully no-ops where the API is unsupported (Firefox as of 2026).
Provider-level default
<ThemeProvider transition="circular">{children}</ThemeProvider>Per-call override
const { setTheme } = useTheme();
setTheme('dark'); // uses provider default
setTheme('dark', { transition: 'circular' }); // override
setTheme('dark', { transition: false }); // skip animation this timeShorthand values
| Value | Effect |
|---|---|
| true or 'fade' | Browser-default cross-fade |
| 'circular' | Circular reveal expanding from the last pointerdown (or viewport center if none) |
| 'none' or false | Disabled |
Full options
<ThemeProvider
transition={{
type: 'circular', // 'fade' | 'circular' | 'none'
duration: 500, // ms
easing: 'cubic-bezier(.4, 0, .2, 1)',
origin: 'cursor', // 'cursor' | 'center' | { x: 100, y: 200 }
css: '...', // fully custom ::view-transition CSS
}}
>Reduced motion
Transitions (and the transition-disabling freeze) are automatically skipped when the user has prefers-reduced-motion: reduce. Pass respectReducedMotion={false} to opt out.
Custom CSS recipe
For a full-page slide reveal:
setTheme(next, {
transition: {
type: 'fade',
duration: 600,
css: `
::view-transition-old(root) { animation: out 600ms cubic-bezier(.4,0,.2,1); }
::view-transition-new(root) { animation: in 600ms cubic-bezier(.4,0,.2,1); }
@keyframes out { to { opacity: 0; transform: translateY(-10px); } }
@keyframes in { from { opacity: 0; transform: translateY( 10px); } }`,
},
});Tailwind
Tailwind v4
Import the preset once in your app CSS; dark: and light: variants work everywhere.
@import "tailwindcss";
@import "@teispace/next-themes/tailwind.css";The preset matches both attribute="class" and attribute="data-theme" providers, so you can switch strategies without changing your markup.
<!-- either of these works -->
<html data-theme="dark">...
<html class="dark">...Add custom variants for additional themes:
@custom-variant sepia (
&:where([data-theme="sepia"], [data-theme="sepia"] *, .sepia, .sepia *)
);
@custom-variant mint (
&:where([data-theme="mint"], [data-theme="mint"] *, .mint, .mint *)
);Usage in markup:
<div class="bg-white text-slate-900 dark:bg-slate-900 dark:text-white sepia:bg-amber-100">
...
</div>Tailwind v3
// tailwind.config.ts
import { darkMode, themeVariant } from '@teispace/next-themes/tailwind';
import plugin from 'tailwindcss/plugin';
export default {
darkMode,
plugins: [
plugin(({ addVariant }) => {
addVariant('sepia', themeVariant('sepia'));
addVariant('mint', themeVariant('mint'));
}),
],
};Storage adapters (advanced)
Each storage mode resolves to a tiny adapter with the shape:
interface StorageAdapter {
get(): string | null;
set(value: string): void;
subscribe?(cb: (value: string | null) => void): () => void;
}You can import adapter factories directly if you need to inspect values or roll your own:
import {
localAdapter,
cookieAdapter,
sessionAdapter,
hybridAdapter,
resolveAdapter,
readCookieFromString,
serializeCookie,
} from '@teispace/next-themes/adapters';
const a = hybridAdapter({ key: 'theme', cookie: { name: 'theme' } });
a.get(); // current value, if any
a.set('dark');resolveAdapter({ mode, key, cookieOptions }) returns the adapter the provider would use for a given config — handy for tests or sync-from-server flows.
Upgrading from 2.x
Most apps need no changes. Everything below is either additive or affects a setup that was already behaving incorrectly.
Breaking
1. Each createThemes() gets its own context.
If you called createThemes() once, nothing changes. If you called it more
than once, the APIs were previously sharing a single module-level context —
whichever provider was nearest in the tree served all of them, so a second
API could silently report the first one's themes. They are now isolated.
Symptom of relying on the old behaviour: a hook from factory A used
without an A.ThemeProvider above it used to pick up B's provider; it now
returns the inert state (theme: '', themes: []). The fix is to render the
matching provider, which is what the code always meant.
<B.ThemeProvider>
+ <A.ThemeProvider>
<ComponentUsingAUseTheme />
+ </A.ThemeProvider>
</B.ThemeProvider>2. main removed from package.json.
It pointed at an ESM file, which misled CJS resolvers into require()-ing ESM.
The package is ESM-only and declares that through exports. Tooling that
resolved via main and appeared to work was relying on a broken path; use a
bundler or Node ≥ 20.9 with ESM.
3. Cookies now default to Secure on HTTPS.
Previously Secure was opt-in, so the theme cookie was sent in cleartext on
otherwise-secure sites. It is now set automatically when
location.protocol === 'https:'. Pass cookieOptions={{ secure: false }} to
opt out (e.g. a plain-HTTP staging host reached over a non-TLS origin).
Server-side callers (setThemeCookie, writeThemeCookie) are unaffected —
there is no location there, so pass secure explicitly.
Not breaking, but worth acting on
engineslowered to>=20.9.0. 2.x declared>=24, which blocked or warned on Node 20/22 LTS for no technical reason. Next.js 16 itself only requires 20.9.sideEffectsis now["*.css"]instead offalse. The old value told bundlers every module was side-effect-free, which permitted them to dropimport '@teispace/next-themes/tailwind.css'— an import with no bindings is exactly what that flag licenses eliding. If your Tailwind preset ever mysteriously failed to apply, this was why.- Fine-grained subpath exports. Importing
useThemefrom the package root pulls a ~7.8 kB chunk;@teispace/next-themes/hooks/use-themeis 509 B. See Exports by subpath. useHydrated()andonStorageErrorare new — see their sections.- View transitions are now typed.
types: ['theme', '<resolvedTheme>']is passed where supported, so you can style from CSS instead of thecssoption. getTheme()infers literal themes when you passthemesas const.
Nothing to do
ThemeProvider props, useTheme, useThemeValue, useThemeEffect,
<ThemedImage>, <ThemedIcon>, <ScopedTheme>, storage modes, the inline
script, and the server helpers are all source-compatible with 2.x.
Migration from next-themes
The API is drop-in compatible for almost every next-themes app. Run the codemod:
npx jscodeshift --parser=tsx \
-t node_modules/@teispace/next-themes/codemod/from-next-themes.cjs \
src/This rewrites:
import ... from 'next-themes'→'@teispace/next-themes'require('next-themes')and dynamicimport('next-themes')export { x } from 'next-themes'andexport * from 'next-themes''next-themes/dist/<sub>'→'@teispace/next-themes/<sub>'
Pass --storage=hybrid (or cookie / local / session / none) to also stamp storage="<mode>" on every <ThemeProvider>:
npx jscodeshift --parser=tsx \
-t node_modules/@teispace/next-themes/codemod/from-next-themes.cjs \
src/ \
--storage=hybridBehavioral differences to be aware of:
- Default
storageis'hybrid'(was localStorage-only). To preserve exact old behavior, setstorage="local". onChange(theme, resolvedTheme)in this lib receives both values (was just the selected value). Harmless change unless you branched on the single argument.- Nested
<ThemeProvider>in this lib maintain independent state — the outer one no longer leaks. If you were relying on nested providers being no-ops, remove the inner ones. <ThemedImage>/<ThemedIcon>no longer use a mount-flag delay — when the provider hasinitialThemeseeded from the cookie, the first render is correct. If you relied onfallbackSrcshowing for a frame, remove that expectation; otherwise this is a strict improvement.ThemeProvider, hooks, and theme-aware components ship with the'use client'directive baked in. You can drop<ThemeProvider>directly into a serverapp/layout.tsx— no wrapper file needed.
Upstream next-themes issues this package resolves:
| # | Issue | How we fix it |
|---|---|---|
| #397 | React 19.2 "Encountered a script tag while rendering React component" when the provider client-remounts (e.g. an app/[lang] locale switch) | The Next entry never renders the script into the React tree at all (useServerInsertedHTML); the generic entry emits it only while un-hydrated and drops it on the first client commit. |
| #389 | localStorage.getItem is not a function on Node 25 (window === globalThis) | Capability probes (hasLocalStorage(), isDom(), ...) instead of typeof window checks. |
| #387 / #385 | Inline <script> warning in React 19 / Next 16 | Script injected via useServerInsertedHTML, or out of the React tree via getThemeScript() in <head>. |
| #375 | Stale theme with cacheComponents / Activity | Store lives outside React via useSyncExternalStore — never stale. |
| #374 | react-hooks/set-state-in-effect ESLint warning | We never setState in effect; external store updates flow through useSyncExternalStore. |
| #373 | Tailwind v4 toggle failure | First-class v4 preset at @teispace/next-themes/tailwind.css. |
| #371 | prefers-color-scheme ignored | Script + store both subscribe to the media query directly. |
| #370 / #369 / #368 | __name is not defined / minification / Cloudflare flash | Inline script body is a raw string literal (not a serialized function); build asserts no __name( survives. |
| #367 | children missing on ThemeProviderProps | Props include children?: ReactNode. |
| #351 | Theme class removed on back/forward | pageshow re-resolves on bfcache restore (script + store). |
| #349 | Generate script string at build time | getThemeScript() exported from /server. |
| #345 | LS overrides system after setTheme | followSystem prop forces system tracking and is honored by the inline script. |
| #326 / #325 | Suspense / hydration boundary breaks with provider | useSyncExternalStore instead of useState + effect. |
| #311 | Image example flickers on initial load | <ThemedImage> no longer waits for mount; honors initialTheme. |
| #308 / #240 | enableSystem={false} + defaultTheme | normalizeSelection enforces the contract end-to-end. |
| #295 | Don't store in localStorage | storage="none". |
| #292 | Multiple classes per theme | value map accepts space-separated class strings. |
| #254 / #215 / #219 | Nested / multi-provider | Per-instance stores; <ScopedTheme> for sub-tree overrides. |
| #242 | Flash with basePath | Script doesn't depend on path; pre-paint placement via getThemeScript() eliminates flash. |
| #236 | Multiple attributes | attribute={['class', 'data-theme']}. |
| #226 / #187 | Force theme per route (App Router) | Route-group <ThemeProvider forcedTheme="...">. |
| #213 | XSS via cookie / forcedTheme value | Inline-script JSON is HTML-safe encoded (</script>, U+2028/9 escaped). |
| #199 | Theme flips after login | Hybrid heal-on-read + bfcache pageshow. |
| #164 | Track system after setTheme | setTheme('system') returns to system tracking; or use followSystem. |
| #149 | Type-safe themes | createThemes<T>() factory with literal-tuple inference. |
| #144 | Class on <body> | target="body". |
| #142 | Custom resolved theme | value map. |
| #78 | theme-color meta sync | themeColor prop. |
| #72 | sessionStorage | storage="session". |
| #70 | finalTheme | Exposed as resolvedTheme. |
| #63 | Storybook forcedTheme not applying class | Forced themes apply to DOM on mount. |
| #48 | Init from server DB | initialTheme prop, paired with getTheme() for cookie reads. |
Genuine open feature requests not in scope: themes-with-themes (#309, #73), storybook addon (#63 has a workaround).
Full API reference
ThemeProvider props
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Tree to wrap. |
| themes | string[] | ['light', 'dark'] | Available theme names. |
| defaultTheme | string | 'system' | Theme used when storage is empty. |
| forcedTheme | string | — | Lock to this value. setTheme becomes a no-op. |
| enableSystem | boolean | true | Enable 'system' as a valid theme + live OS detection. |
| followSystem | boolean | false | Always track OS preference, ignoring storage. |
| attribute | Attribute \| Attribute[] | 'data-theme' | HTML attribute(s) on the target element. |
| value | Record<string, string> | — | Map logical theme → attribute value (supports multi-class). |
| target | string (selector) | 'html' | Element that receives the attribute. |
| storage | 'hybrid' \| 'cookie' \| 'local' \| 'session' \| 'none' | 'hybrid' | Persistence strategy. |
| storageKey | string | 'theme' | Key for local/session storage and fallback cookie name. |
| cookieOptions | CookieOptions | — | Overrides (name, maxAge, path, domain, sameSite, secure). |
| disableTransitionOnChange | boolean \| string | false | Freeze CSS transitions during a switch. Pass a CSS string to scope the freeze. |
| respectReducedMotion | boolean | true | Skip transition-disable + View Transitions when user prefers reduced motion. |
| enableColorScheme | boolean | true | Sync style.colorScheme on the target element. |
| themeColor | string \| Record<string, string> | — | Sync <meta name="theme-color">. |
| initialTheme | string | — | Seed from the server (cookie, DB, session). |
| nonce | string | — | CSP nonce for the inline anti-FOUC script. |
| noScript | boolean | false | Skip the inline script. Use when you have rendered getThemeScript() in <head> yourself — most flicker-resistant placement. |
| transition | TransitionConfig | — | Animate theme changes via View Transitions. |
| onChange | (theme, resolvedTheme) => void | — | Fires on every theme change. |
setTheme(theme, options?)
setTheme('dark');
setTheme('dark', {
// Override the provider-level transition for this call.
transition: false | true | 'fade' | 'circular' | TransitionOptions,
});Types
type Attribute = 'class' | `data-${string}`;
type StorageMode = 'hybrid' | 'cookie' | 'local' | 'session' | 'none';
interface CookieOptions {
name?: string;
maxAge?: number;
path?: string;
domain?: string;
sameSite?: 'strict' | 'lax' | 'none';
secure?: boolean;
}
type TransitionType = 'fade' | 'circular' | 'none';
type TransitionOrigin = 'cursor' | 'center' | { x: number; y: number };
interface TransitionOptions {
type?: TransitionType;
duration?: number; // ms
easing?: string; // CSS timing function
origin?: TransitionOrigin;
css?: string; // custom ::view-transition CSS
}
type TransitionConfig = boolean | TransitionType | TransitionOptions;
interface ThemeState {
theme: string;
resolvedTheme: string;
systemTheme: 'light' | 'dark' | null;
forcedTheme: string | null;
themes: string[];
}useHydrated()
false during the server render and the hydration render, true afterwards.
Use it to gate browser-only UI without a hydration mismatch.
import { useHydrated } from '@teispace/next-themes/hooks/use-hydrated';
const hydrated = useHydrated();
return <span>{hydrated ? resolvedTheme : null}</span>;You usually don't need it for theming itself — <ThemedImage>, <ThemedIcon>
and useTheme() already read the store's seeded server snapshot.
onStorageError
Storage failures are non-fatal by design (Safari private mode, sandboxed iframes, disabled cookies, quota exceeded) — theming must never break because persistence did. They used to be silent, which made "my theme doesn't stick" undebuggable. Wire this to your logger:
<ThemeProvider
onStorageError={(error, { operation, key }) => {
reportToSentry(error, { tags: { operation, key } });
}}
>View transition types
Theme changes are stamped with view-transition types — theme plus the resolved
theme name — so you can style them from your own CSS instead of passing a css
string:
:root(:active-view-transition-type(dark))::view-transition-new(root) {
animation: slide-in 300ms ease;
}Applied only on engines that support :active-view-transition-type()
(Chrome/Edge 125+, Safari 18.2+, Firefox 147+); elsewhere the built-in
fade/circular CSS still runs.
Exports by subpath
| Import | Purpose |
|---|---|
| @teispace/next-themes | Next.js App Router provider + hooks + components + createThemes |
| @teispace/next-themes/client | Same API, generic React (Vite, Remix, CRA) |
| @teispace/next-themes/server | getTheme, getThemeScript, setThemeCookie, writeThemeCookie, readColorSchemeHint, acceptClientHintsHeader |
| @teispace/next-themes/adapters | Storage adapter factories + cookie helpers |
| @teispace/next-themes/script | buildScript({...}) — raw inline script builder |
| @teispace/next-themes/tailwind | v3 darkMode + themeVariant(name) helpers |
| @teispace/next-themes/tailwind.css | v4 CSS preset (import in your global CSS) |
| @teispace/next-themes/codemod/from-next-themes | jscodeshift transform file |
Fine-grained entries. Import a single hook or component when you don't need the rest of the package — a leaf that only reads the theme shouldn't pull in the providers, the factory, and every themed component. All of these share one internal chunk, so there is still exactly one store and one context at runtime.
| Import | gzip (transitive) |
|---|---|
| @teispace/next-themes/hooks/use-theme | 509 B |
| @teispace/next-themes/hooks/use-theme-value | 601 B |
| @teispace/next-themes/hooks/use-theme-effect | 613 B |
| @teispace/next-themes/hooks/use-hydrated | 177 B |
| @teispace/next-themes/components/themed-image | 708 B |
| @teispace/next-themes/components/themed-icon | 693 B |
| @teispace/next-themes/components/scoped-theme | 541 B |
| (full @teispace/next-themes entry, for comparison) | 7,797 B |
Recipes
Sync theme to a user profile on the server
'use client';
import { ThemeProvider } from '@teispace/next-themes';
<ThemeProvider
onChange={async (theme) => {
await fetch('/api/me/theme', {
method: 'POST',
body: JSON.stringify({ theme }),
});
}}
>
{children}
</ThemeProvider>Force a theme on a specific route
// app/print/layout.tsx
import { ThemeProvider } from '@teispace/next-themes';
export default function PrintLayout({ children }) {
return (
<ThemeProvider forcedTheme="light" storage="none" enableSystem={false}>
{children}
</ThemeProvider>
);
}Multi-brand with per-brand defaults
// app/[brand]/layout.tsx
import { ThemeProvider } from '@teispace/next-themes';
const BRAND_DEFAULTS: Record<string, { default: string; themes: string[] }> = {
acme: { default: 'acme-light', themes: ['acme-light', 'acme-dark'] },
globex: { default: 'globex-light', themes: ['globex-light', 'globex-dark'] },
};
export default function BrandLayout({ children, params }) {
const cfg = BRAND_DEFAULTS[params.brand];
return (
<ThemeProvider themes={cfg.themes} defaultTheme={cfg.default}>
{children}
</ThemeProvider>
);
}CSS variables driven by theme tokens
:root { --bg: 0 0% 100%; --fg: 0 0% 10%; }
.dark { --bg: 220 15% 7%; --fg: 0 0% 96%; }
.sepia { --bg: 40 30% 94%; --fg: 25 25% 20%; }
body {
background: hsl(var(--bg));
color: hsl(var(--fg));
}Build a theme toggle group
'use client';
import { useTheme } from '@teispace/next-themes';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<div role="group" aria-label="Theme">
{(['light', 'system', 'dark'] as const).map((t) => (
<button
key={t}
type="button"
aria-pressed={theme === t}
onClick={() => setTheme(t)}
>
{t}
</button>
))}
</div>
);
}Access the theme in a non-React context
Import the adapter directly — useful for vanilla scripts or helpers outside the React tree:
import { resolveAdapter } from '@teispace/next-themes/adapters';
const adapter = resolveAdapter({ mode: 'hybrid', key: 'theme' });
const currentTheme = adapter.get(); // 'light' | 'dark' | 'system' | nullOr read the raw cookie on the server:
import { readCookieFromString } from '@teispace/next-themes/server';
const theme = readCookieFromString(req.headers.get('cookie') ?? '', 'theme');Troubleshooting / FAQ
I see a hydration warning on <html>
Add suppressHydrationWarning to your <html> tag. The library already does the right thing — the warning is inherent to any inline script that mutates attributes before hydration.
The first paint is still flashing
Causes, in order of likelihood:
- Script runs too late.
useServerInsertedHTMLplaces the inline script at the next React flush boundary, which on streamed responses can be inside<body>— after some pixels paint. Switch to the zero-flicker setup: rendergetThemeScript()directly in<head>and passnoScriptto your provider. This is the most reliable fix. - Storage isn't server-readable.
storage="local"andstorage="session"are client-only. Switch to'hybrid'(default) or'cookie'. initialThemenot wired. Without it, the server renders withdefaultTheme, which may disagree with the cookie-stored preference and cause a one-frame flip:const initialTheme = await getTheme(); <ThemeProvider initialTheme={initialTheme ?? undefined}>- Cookie blocked / expired. Hybrid storage now self-heals: when the cookie is missing but localStorage has a value, the cookie is rewritten on read so the server sees it on the next request. If you still see a flash, check that the cookie isn't blocked by an iframe sandbox, ITP, or a CDN that strips
Set-Cookie.
Theme briefly flips when navigating back from another page
This is the browser's bfcache (back/forward cache) showing a stale snapshot. The library now re-runs the inline script on pageshow when event.persisted === true, and the React store also re-reads storage at the same time. If you've forked the script or pinned an old version, upgrade.
The circular reveal doesn't expand from the click
origin: 'cursor' uses the last pointerdown position. If you trigger setTheme from a keyboard event (e.g. a keyboard shortcut) there's no click; it falls back to viewport center. Pass explicit coordinates when you need deterministic origin:
setTheme(next, { transition: { type: 'circular', origin: { x: 0, y: 0 } } });Tailwind's dark: utilities don't work
Either import the v4 preset (@import "@teispace/next-themes/tailwind.css";) or set darkMode: ['variant', ['&:where([data-theme="dark"] *)', '&:where(.dark *)']] in your Tailwind v3 config.
I get "useTheme() called outside a ThemeProvider"
The hook logs this in dev when useContext(ThemeStoreContext) returns null — i.e. there's no <ThemeProvider> ancestor in the React tree at the point useTheme() runs. The hook then returns inert defaults instead of throwing, so the page still renders. In React Strict Mode you'll see the message printed twice (one render per pass) — that's expected, not a second bug.
If your code clearly wraps the consumer in <ThemeProvider> and you still see this, check in order:
The consumer really is a descendant. It's easy to read your tree and miss that the call lives in a parallel route slot, an
error.tsx/not-found.tsxrendered above the provider, a server-side path (Route Handler,metadata, RSC running before the client provider mounts), or a portal that escapes the subtree. The provider only covers what's rendered aschildren.Two copies of
@teispace/next-themesinnode_modules. If a dependency pulls in its own copy at a different version, your code and that dependency import differentThemeStoreContextobjects, souseContexton one returnsnulleven though the other is mounted. Runnpm ls @teispace/next-themes(orpnpm why/yarn why); if you see more than one entry, dedupe (npm dedupe,pnpm dedupe).Stale build cache after upgrade.
rm -rf node_modules .next && <your-pkg-mgr> installrules this out.Mixed entries are fine. Importing
<ThemeProvider>from@teispace/next-themesanduseThemefrom@teispace/next-themes/client(or vice versa) shares the same context — the subpaths re-export the same module — so this isn't the cause.
My CSP blocks the inline script
Pass a nonce:
import { headers } from 'next/headers';
const nonce = (await headers()).get('x-nonce') ?? undefined;
<ThemeProvider nonce={nonce}>{children}</ThemeProvider>I need per-theme images but want zero flash on SSR
Use CSS rather than <ThemedImage> for SSR-perfect switching:
.logo-light { display: block; }
.logo-dark { display: none; }
html[data-theme="dark"] .logo-light { display: none; }
html[data-theme="dark"] .logo-dark { display: block; }<img className="logo-light" src="/logo-light.svg" alt="" />
<img className="logo-dark" src="/logo-dark.svg" alt="" />Both are server-rendered; the inline script flips data-theme before first paint, so only the right one is ever visible.
Does it work with React Server Components?
Yes. The provider itself is a client component (as is any stateful hook provider), but you can call getTheme() and setThemeCookie() / writeThemeCookie() from RSC and Server Actions via the /server entry. The <html> in your root layout stays a server component.
How do I test it?
The package ships tests for the anti-FOUC script (direct execution in a sandbox), the store, adapters, hooks, components, the factory, and the codemod. If you're testing your integration:
- Use
happy-domorjsdom(Node ≥ 22 users: passNODE_OPTIONS='--no-experimental-webstorage'so your DOM impl'sStorageisn't shadowed — the package does this for its own test suite and documents it here). - For Playwright, the anti-FOUC guarantee is observable as "first painted frame has the correct theme"; script evaluation happens before
DOMContentLoaded.
Performance & bundle sizes
All ESM, unminified — subject to your bundler's minification + gzip. React is externalized.
| Entry | Minified (approx) | Notes |
|---|---:|---|
| . (Next.js) | ~3 KB | Includes useServerInsertedHTML path |
| /client | ~2.9 KB | Inline-script path |
| /server | ~2.2 KB | Cookie + client-hint helpers |
| /adapters | ~0.3 KB | Re-exports only (tree-shakes to near zero) |
| /script | ~0.1 KB | Standalone script builder |
| /tailwind (JS) | ~0.5 KB | v3 config helper |
| tailwind.css | raw CSS preset | Published unbundled |
Shared code (store, DOM, view transitions, cursor tracker) lives in tree-shakeable chunks; you only load what an entry imports.
Runtime overhead:
useSyncExternalStoreis React-native — no polyfill.- The inline anti-FOUC script is ~3.5 KB HTML-embedded (runs once per page load).
- Cursor tracker is a single passive
pointerdownlistener ondocument, installed lazily on first provider mount.
Browser support
| Feature | Support |
|---|---|
| Core | All browsers supported by React 18+ |
| useSyncExternalStore | React 18 native |
| matchMedia + Storage | Evergreen + IE11 (with polyfills) |
| Cookie storage | Evergreen + IE11 |
| View Transitions | Chromium 111+, Safari 18+, Firefox n/a (graceful fallback) |
| Sec-CH-Prefers-Color-Scheme | Chromium 93+ (opt-in), behind flag elsewhere |
The View Transitions path detects support and degrades to synchronous theme application where unsupported, with no visual glitches.
Examples app
A full Next.js 16 app with a runnable demo for every feature:
# from the monorepo root
yarn workspace next-themes-examples devSee examples/next-themes/. Each route maps to a section of this README.
License
MIT © Teispace
Server runtimes: the storage adapters only probe
localStoragewhen adocumentexists, so Node 22+ prints no--localstorage-filewarning during server renders or builds.
