ThemeProvider
API reference for the ThemeProvider component.
Last updated on
ThemeProvider wraps your app, injects an anti-flash script before hydration, and manages theme state via ClientThemeProvider.
Breaking · v2.0.0-beta.1 In 1.x, ThemeProvider from @wrksz/themes/next was an async Server Component that called cookies() for cookie/hybrid storage. From 2.0.0-beta.1 it is synchronous and never reads cookies on the server. See Upgrading from 1.x.
Two variants are available depending on your framework:
| Import | Mechanism | Use when |
|---|---|---|
@wrksz/themes/next | useServerInsertedHTML for <html>; synchronous adjacent script for later targets | Next.js 16+ (no React 19 warning) |
@wrksz/themes | Client-only provider | Nested scopes and client-rendered React |
@wrksz/themes/script | Server-rendered bootstrap script | Other SSR frameworks |
import { ThemeProvider } from "@wrksz/themes/next";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}Use ThemeProvider from @wrksz/themes/next directly in a server layout. The next entry also exports the server-only getTheme helper, so it is not a client-module entry. For nested providers inside Client Components, use ClientThemeProvider.
// ❌ do not import the Next server entry from a client module
"use client";
import { ThemeProvider } from "@wrksz/themes/next";
export function Providers({ children }) {
return <ThemeProvider>{children}</ThemeProvider>;
}// ✅ use directly in layout.tsx
import { ThemeProvider } from "@wrksz/themes/next";
export default function RootLayout({ children }) {
return (
<html suppressHydrationWarning>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
themes | readonly string[] | ["light", "dark"] | Available themes. Use as const to preserve custom theme unions in TypeScript |
defaultTheme | string | "system" | Theme used when no preference is stored |
forcedTheme | string | - | Force a specific theme, ignoring user preference. Breaking · v2.0.0-beta.1 Does not write to storage on init. |
initialTheme | string | - | Server-provided theme that overrides storage on mount. User can still call setTheme to change it. Breaking · v2.0.0-beta.1 Mount init is sticky: later prop changes to storageKey / initialTheme do not re-run initialization (forcedTheme still updates via overlay). |
enableSystem | boolean | true | Detect system preference via prefers-color-scheme |
enableColorScheme | boolean | true | Set native color-scheme CSS property |
attribute | "class" | "data-*" | ("class" | "data-*")[] | "class" | HTML attribute(s) to set on the target element |
value | Record<string, string> | - | Map theme names to attribute values |
target | string | "html" | Element to apply theme to ("html", "body", or a CSS selector) |
storageKey | string | "theme" | Key used for storage |
storage | "localStorage" | "sessionStorage" | "cookie" | "hybrid" | "none" | "localStorage" | Where to persist the theme. The bootstrap reads cookies synchronously before paint. "hybrid" prefers the cookie and mirrors writes to localStorage for cross-tab sync. Breaking · v2.0.0-beta.1 Next provider no longer reads the cookie via cookies() on the server. |
cookieOptions | CookieOptions | - | Cookie attributes applied when writing the theme cookie. Only used when storage="cookie". See CookieOptions. |
disableTransitionOnChange | boolean | string | false | Suppress CSS transitions on initial load and when switching themes. true disables all transitions. Pass a CSS transition value (e.g. "background-color 0s, color 0s") to suppress only specific properties while keeping others (transforms, opacity, etc.) intact. |
followSystem | boolean | false | Always follow system preference, ignores stored value on mount |
themeColor | string | Record<string, string> | - | Update <meta name="theme-color"> on theme change |
nonce | string | - | CSP nonce for the inline script |
scriptProps | ScriptHTMLAttributes<HTMLScriptElement> | - | Extra attributes for the bootstrap script, such as data-cfasync="false" |
onStorageError | (error: unknown) => void | - | Reports unavailable, blocked, or quota-limited storage without interrupting theme updates |
systemThemeMap | { light: string; dark: string } | Record<string, { light: string; dark: string }> | - | Serializable custom resolution for system-aware variants |
themeRoot | Element | ShadowRoot | - | Client-only Shadow DOM/custom root. Use target when the root must be themed before hydration |
onThemeChange | (theme: string) => void | - | Called whenever the theme changes. Receives the selected value (may be "system"). When system preference changes while theme is "system", fires with the resolved value ("light" or "dark"). |
Security notes
ThemeProvider injects a small inline script to apply the initial theme before React hydrates.
Configuration values are escaped for the <script> context, including themeColor, value,
themes, forcedTheme, and initialTheme.
When your app uses a Content Security Policy, pass a request-scoped nonce and include the
same value in script-src.
Cookie storage treats cookies as untrusted input. Stored values are validated against themes
when provided, malformed cookie encoding falls back to defaultTheme, and cookie attributes
are validated before writes.
Examples
Custom themes
const themes = ["light", "dark", "high-contrast"] as const;
<ThemeProvider themes={themes} defaultTheme="high-contrast">
{children}
</ThemeProvider>For full end-to-end inference across setTheme, useThemeValue, and theme-aware components, use createThemes with the same tuple.
Data attribute instead of class
<ThemeProvider attribute="data-theme">
{children}
</ThemeProvider>Multiple classes per theme
Map a theme to multiple CSS classes using a space-separated value:
<ThemeProvider
themes={["light", "dark", "dim"]}
value={{ light: "light", dark: "dark high-contrast", dim: "dark dim" }}
>
{children}
</ThemeProvider>Force a theme per page
<ThemeProvider forcedTheme="dark">
{children}
</ThemeProvider>Server-provided theme
Use initialTheme to initialize from a server-side source (database, session, cookie) on every mount:
export default async function RootLayout({ children }) {
const userTheme = await getUserTheme();
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
initialTheme={userTheme ?? undefined}
onThemeChange={saveUserTheme}
>
{children}
</ThemeProvider>
</body>
</html>
);
}Cookie storage (zero-flash SSR)
Breaking · v2.0.0-beta.1 1.x: @wrksz/themes/next called cookies() and could set initialTheme for you. 2.0.0-beta.1: zero-flash comes from the pre-paint bootstrap only; the provider stays static. Explicit SSR markup still needs getTheme.
When using @wrksz/themes/next, storage="cookie" keeps the provider in the static App Shell. Its bootstrap reads document.cookie synchronously and applies the class before the first paint:
import { ThemeProvider } from "@wrksz/themes/next";
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="dark"
storage="cookie"
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}Theme changes are written to the cookie automatically. If server-rendered markup must also depend on the theme, use getTheme explicitly.
Cookie storage does not support cross-tab theme sync. If you need cross-tab sync, use localStorage with initialTheme.
Hybrid storage (SSR + cross-tab sync)
Breaking · v2.0.0-beta.1 In 1.x, hybrid storage also triggered a server cookies() read on the Next provider.
storage="hybrid" combines the best parts of cookie and local storage:
- Read priority: cookie first, then
localStorage - Writes: cookie and
localStorage - App Shell: the bootstrap reads the cookie without making the layout request-time dynamic (1.x used server
cookies()here) - Cross-tab: changes propagate through the
storageevent
import { ThemeProvider } from "@wrksz/themes/next";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider storage="hybrid" defaultTheme="system">
{children}
</ThemeProvider>
</body>
</html>
);
}Next.js 16.3 Instant Navigations
Breaking · v2.0.0-beta.1 New in 2.0.0-beta.1 as a consequence of removing implicit cookies(). In 1.x the Next provider was request-time for cookie/hybrid storage. See Upgrading from 1.x.
ThemeProvider does not call request-time APIs, so it can stay in a reusable App Shell when
cacheComponents and partialPrefetching are enabled. Prefetches only render the static
provider configuration; they never write theme cookies.
next/root-params is available by default in Next.js 16.3. Themes do not require a root-param
integration. If your product intentionally stores a different theme per tenant or locale, scope
the key in your app:
import { tenant } from "next/root-params";
import { ThemeProvider } from "@wrksz/themes/next";
export default async function TenantLayout({ children }) {
const currentTenant = await tenant();
return (
<ThemeProvider storageKey={`theme-${currentTenant}`}>
{children}
</ThemeProvider>
);
}Sharing cookie across subdomains
Use cookieOptions.domain to share the theme preference between subdomains (e.g. app.example.com and api.example.com).
Works with both storage="cookie" and storage="hybrid":
<ThemeProvider
storage="hybrid"
cookieOptions={{ domain: ".example.com" }}
>
{children}
</ThemeProvider>All cookieOptions fields have sensible defaults - you only need to specify what you want to override.
Suppress transitions on theme change
Suppress CSS transitions only when the library actually changes the applied theme: during the inline script's initial run when SSR markup needs correction, and whenever the user switches themes. This prevents first-paint flash and animated jank without reinserting a transition override during no-op hydration:
<ThemeProvider disableTransitionOnChange>
{children}
</ThemeProvider>To keep some transitions intact (e.g. hover effects, animations) while only suppressing color-related ones:
<ThemeProvider disableTransitionOnChange="background-color 0s, color 0s, border-color 0s, fill 0s, stroke 0s">
{children}
</ThemeProvider>The string is injected as transition: <value> !important on all elements for two animation frames, but only around a real DOM theme update.
Always follow system preference
Use followSystem to ignore any stored value and always apply the system preference. Useful for apps where you want the theme to stay in sync with the OS without letting users override it:
<ThemeProvider followSystem>
{children}
</ThemeProvider>Unlike defaultTheme="system", which applies the system preference only on first visit and then stores the resolved value, followSystem re-reads prefers-color-scheme on every mount and ignores the stored value entirely.
Disable storage
<ThemeProvider storage="none" defaultTheme="dark">
{children}
</ThemeProvider>Use this with your consent manager rather than giving the theme library control over consent:
<ThemeProvider storage={hasPreferenceConsent ? "localStorage" : "none"}>
{children}
</ThemeProvider>Remove the previous key when consent is revoked if your policy requires deletion; storage="none" guarantees no further reads or writes.
Observe storage failures
<ThemeProvider onStorageError={(error) => reportError(error)}>
{children}
</ThemeProvider>The selected theme still updates in memory and on the DOM when persistence is unavailable.
Meta theme-color
<ThemeProvider themeColor={{ light: "#ffffff", dark: "#0a0a0a" }}>
{children}
</ThemeProvider>Works with CSS variables too:
<ThemeProvider themeColor="var(--color-background)">
{children}
</ThemeProvider>CookieOptions
Options applied when writing the theme cookie. Only relevant when storage="cookie".
| Field | Type | Default | Description |
|---|---|---|---|
domain | string | current domain | Cookie domain, e.g. ".example.com" to share across subdomains |
maxAge | number | 31536000 | Max age in seconds (1 year) |
sameSite | "Strict" | "Lax" | "None" | "Lax" | SameSite attribute |
secure | boolean | true on HTTPS | Whether to add the Secure flag |
path | string | "/" | Cookie path |