@wrksz/themesv2.0.0-beta.1 · beta
API Reference

ThemeProvider

API reference for the ThemeProvider component.

Edit on GitHub

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:

ImportMechanismUse when
@wrksz/themes/nextuseServerInsertedHTML for <html>; synchronous adjacent script for later targetsNext.js 16+ (no React 19 warning)
@wrksz/themesClient-only providerNested scopes and client-rendered React
@wrksz/themes/scriptServer-rendered bootstrap scriptOther SSR frameworks
app/layout.tsx (Next.js)
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

PropTypeDefaultDescription
themesreadonly string[]["light", "dark"]Available themes. Use as const to preserve custom theme unions in TypeScript
defaultThemestring"system"Theme used when no preference is stored
forcedThemestring-Force a specific theme, ignoring user preference. Breaking · v2.0.0-beta.1 Does not write to storage on init.
initialThemestring-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).
enableSystembooleantrueDetect system preference via prefers-color-scheme
enableColorSchemebooleantrueSet native color-scheme CSS property
attribute"class" | "data-*" | ("class" | "data-*")[]"class"HTML attribute(s) to set on the target element
valueRecord<string, string>-Map theme names to attribute values
targetstring"html"Element to apply theme to ("html", "body", or a CSS selector)
storageKeystring"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.
cookieOptionsCookieOptions-Cookie attributes applied when writing the theme cookie. Only used when storage="cookie". See CookieOptions.
disableTransitionOnChangeboolean | stringfalseSuppress 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.
followSystembooleanfalseAlways follow system preference, ignores stored value on mount
themeColorstring | Record<string, string>-Update <meta name="theme-color"> on theme change
noncestring-CSP nonce for the inline script
scriptPropsScriptHTMLAttributes<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
themeRootElement | 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

app/dashboard/layout.tsx
<ThemeProvider forcedTheme="dark">
  {children}
</ThemeProvider>

Server-provided theme

Use initialTheme to initialize from a server-side source (database, session, cookie) on every mount:

app/layout.tsx
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>
  );
}

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:

app/layout.tsx
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 storage event
app/layout.tsx
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:

app/[tenant]/layout.tsx
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>
  );
}

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":

app/layout.tsx
<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".

FieldTypeDefaultDescription
domainstringcurrent domainCookie domain, e.g. ".example.com" to share across subdomains
maxAgenumber31536000Max age in seconds (1 year)
sameSite"Strict" | "Lax" | "None""Lax"SameSite attribute
securebooleantrue on HTTPSWhether to add the Secure flag
pathstring"/"Cookie path

On this page