@wrksz/themes
!next.js 16+
!react 18 and 19
!typescript 5.9 to 7
Theme management for Next.js 16+ and React 18 or 19. Supports custom themes, cookie and browser storage, scoped providers, and typed hooks. No runtime dependencies.
The API follows next-themes conventions. See the migration guide for import changes and behavior differences.
TypeScript 5.9 or newer is required. TypeScript 5.9, 6, and 7 are supported and checked against the published package declarations in CI.
Upgrading from 1.x: The Next.js provider no longer reads cookies on the server. UsegetTheme()andinitialThemewhen server-rendered content needs the theme. Version 2 also changes forced-theme persistence and initialization behavior. See Upgrading from 1.x.
pnpm add @wrksz/themes
or
npm install @wrksz/themes
Why not next-themes?
| | next-themes | @wrksz/themes |
| ----------------------------------------------- | ----------- | ------------------------------- |
| React 19 script warning | Bug | Fixed (useServerInsertedHTML) |
| __name minification bug | Bug | Fixed |
| Stale theme with React 19 cacheComponents | Bug | Fixed (useSyncExternalStore) |
| Multi-class theme removal leaving stale classes | Bug | Fixed |
| Nested providers | No | Yes (per-instance store) |
| sessionStorage support | No | Yes |
| cookie storage (zero-flash SSR) | No | Yes |
| hybrid storage (SSR + cross-tab sync) | No | Yes |
| Disable storage | No | Yes (storage="none") |
| meta theme-color support | No | Yes (themeColor prop) |
| Server-provided theme | No | Yes (initialTheme prop) |
| disableTransitionOnChange per property | No | Yes (pass a CSS string) |
| Read theme outside React | No | Yes (getTheme() helper) |
| Generic types | No | Yes (useTheme) |
| Typed factory | No | Yes (createThemes(...)) |
| Theme-change effect hook | No | Yes (useThemeEffect(...)) |
| Hydration state without mount effects | No | Yes (useHydrated()) |
| Framework-neutral SSR bootstrap | No | Yes (@wrksz/themes/script) |
| Zero runtime dependencies | Yes | Yes |
Table of Contents
- ThemeProvider - Extended provider - useTheme - getTheme - useThemeValue - useThemeEffect - createThemes - ThemedImageSetup
Add the provider to your root layout. Import from @wrksz/themes/next for Next.js - this avoids the React 19 inline script warning by using useServerInsertedHTML. Add suppressHydrationWarning to to suppress the expected attribute mismatch when the bootstrap applies the theme before hydration.
// app/layout.tsx
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>
);
}
Note: UseThemeProviderfrom@wrksz/themes/nextdirectly in a serverlayout.tsx. For nested providers inside Client Components, useClientThemeProvider.
The Next provider is static and compatible with Next.js 16.3 Instant Navigations,
cacheComponents, and Partial Prefetching. It does not mutate cookies during prefetches.
Use getTheme() only when server-rendered markup must depend on the cookie; under Cache
Components, isolate that request-time read with Suspense or opt the route out with
export const instant = false.
Usage
"use client";
import { useTheme } from "@wrksz/themes/client";
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}>
Toggle theme
</button>
);
}
Zero-flash SSR with cookie storage
Use storage="cookie" with @wrksz/themes/next to eliminate theme flash. The static bootstrap reads the cookie synchronously before paint, so the provider remains compatible with Next.js 16.3 App Shells and Partial Prefetching:
// 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="cookie" defaultTheme="dark" disableTransitionOnChange>
{children}
</ThemeProvider>
</body>
</html>
);
}
For apps using CSS media queries (@media (prefers-color-scheme: dark)) alongside CSS class variables, avoid the media query fallback - the library sets the correct class before the first paint. A media query fallback flashes when the system preference differs from the stored theme:
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--bg: #09090b;
}
}
Use class-based selectors instead:
:root {
--bg: #ffffff;
}
:root.dark {
--bg: #09090b;
}
Cookie storage alone does not support cross-tab theme sync. Usestorage="hybrid"to combine cookies with cross-tab synchronization, orlocalStorageif you do not need cookies.
Security model
@wrksz/themes injects a small inline script so the correct theme can be applied before
React hydrates. Script config is serialized with script-context escaping, so values such as
themeColor, value, themes, forcedTheme, and initialTheme cannot break out of the
tag via payloads.
If your app uses a Content Security Policy, pass a request-scoped nonce to ThemeProvider
and include the same nonce in your script-src policy.
Cookie storage treats cookies as untrusted input: stored values are validated against
themes when a theme list is provided, malformed cookie encoding falls back to
defaultTheme, and cookie attributes are validated before writes. Releases are published
with npm provenance from GitHub Actions.
API
ThemeProvider
The canonical prop table lives on the docs site: ThemeProvider.
import { ThemeProvider } from "@wrksz/themes/next";
Opt-in extended provider
The default provider stays intentionally small. Import the extended provider only when you need
same-document synchronization, custom system mappings, or a client-owned Element/ShadowRoot:
import { ThemeProvider } from "@wrksz/themes/next/extended";
<ThemeProvider
themes={["paper", "midnight"]}
systemThemeMap={{ light: "paper", dark: "midnight" }}
enableSameDocumentSync
>
{children}
</ThemeProvider>;
The Next.js extended provider is synchronous and does not read cookies on the
server. It supports the serializable systemThemeMap and
enableSameDocumentSync props. For a DOM object, use the client-only entry:
"use client";
import { ClientThemeProvider } from "@wrksz/themes/client/extended-provider";
<ClientThemeProvider themeRoot={shadowRoot} storage="none" defaultTheme="dark">
{children}
</ClientThemeProvider>;
enableSameDocumentSync?: booleansynchronizes providers with the same storage key.systemThemeMap?: { light: Theme; dark: Theme } | Record
themeRoot?: Element | ShadowRoottargets a client-owned element or a ShadowRoot host. Worked example: Shadow DOM.
useTheme
const {
theme, // Current theme - may be "system"
resolvedTheme, // Actual theme - never "system"
systemTheme, // System preference: "light" | "dark" | undefined
forcedTheme, // Forced theme if set
themes, // Available themes
setTheme, // Set theme
} = useTheme();
Supports generics for full type safety:
type AppTheme = "light" | "dark" | "high-contrast";
const { theme, setTheme } = useTheme<AppTheme>();
// theme: AppTheme | "system" | undefined
// setTheme: (theme: AppTheme | "system") => void
getTheme
Reads the current theme from a cookie outside React. Available in @wrksz/themes/next.
// proxy.ts - sync, reads from Request
import { getTheme } from "@wrksz/themes/next";
export function proxy(request: Request) {
const theme = getTheme(request, { defaultTheme: "dark" });
}
// layout.tsx - async, reads via cookies() from next/headers
const theme = await getTheme({ defaultTheme: "dark" });
return <html className={theme}>...</html>;
Pass themes as a readonly tuple to infer the return type:
const theme = getTheme(request, {
themes: ["light", "dark", "high-contrast"] as const,
defaultTheme: "light",
});
// theme: "light" | "dark" | "high-contrast"
See the getTheme reference for options and server-rendering examples.
useThemeValue
Returns the value from a map matching the current resolved theme. Returns undefined before the theme resolves on the client.
"use client";
import { useThemeValue } from "@wrksz/themes/client";
const label = useThemeValue({ light: "Switch to dark", dark: "Switch to light" });
const bg = useThemeValue({ light: "#ffffff", dark: "#0a0a0a" });
const icon = useThemeValue({ light: <SunIcon />, dark: <MoonIcon /> });
useThemeEffect
Runs an effect after mount whenever the theme changes:
"use client";
import { useThemeEffect } from "@wrksz/themes/client";
useThemeEffect((theme, resolvedTheme) => {
trackThemeChange(theme, resolvedTheme);
});
createThemes
Create a typed theme module once and reuse it everywhere:
"use client";
import { createThemes } from "@wrksz/themes/client";
export const { ThemeProvider, useTheme, useThemeValue, useThemeEffect } = createThemes({
themes: ["light", "dark", "high-contrast"] as const,
storage: "hybrid",
defaultTheme: "system",
attribute: "class",
});
For a Next.js root layout, import from @wrksz/themes/next/create-themes and use NextThemeProvider so the theme tuple is not copied into @wrksz/themes/next. Full API: createThemes.
ThemedImage
Shows different images per theme. Renders a transparent placeholder on the server to avoid hydration mismatches.
import { ThemedImage } from "@wrksz/themes/client";
<ThemedImage
src={{ light: "/logo-light.png", dark: "/logo-dark.png" }}
alt="Logo"
width={200}
height={50}
/>;
Examples
Custom themes
<ThemeProvider themes={["light", "dark", "high-contrast"]}>{children}</ThemeProvider>
Data attribute instead of class
<ThemeProvider attribute="data-theme">{children}</ThemeProvider>
[data-theme="dark"] {
--bg: #000;
}
[data-theme="light"] {
--bg: #fff;
}
Multiple classes per theme
<ThemeProvider
themes={["light", "dark", "dim"]}
value={{ light: "light", dark: "dark high-contrast", dim: "dark dim" }}
>
{children}
</ThemeProvider>
Switching away from "dark" correctly removes both dark and high-contrast.
Forced theme per page
// app/dashboard/layout.tsx
<ThemeProvider forcedTheme="dark">{children}</ThemeProvider>
Scoped theming
Apply the theme to a specific element instead of , so different sections can have independent themes simultaneously:
<ThemeProvider forcedTheme="dark" target="#landing-root" storage="none">
<div id="landing-root">{children}</div>
</ThemeProvider>
#landing-root {
--bg: #0a0a0a;
--fg: #fafafa;
}
Server-provided theme
Use initialTheme to initialize from a server-side source such as a database or session. It takes precedence over storage at mount; later prop changes do not reinitialize the provider:
export default async function RootLayout({ children }) {
const userTheme = await getUserTheme();
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider initialTheme={userTheme ?? undefined}>{children}</ThemeProvider>
</body>
</html>
);
}
Nested provider in a Client Component
"use client";
import { ClientThemeProvider } from "@wrksz/themes/client";
export function AdminShell({ children }: { children: React.ReactNode }) {
return <ClientThemeProvider forcedTheme="dark">{children}</ClientThemeProvider>;
}
Suppress transitions on theme change
// Disable all transitions
<ThemeProvider disableTransitionOnChange>
{children}
</ThemeProvider>
// Suppress only color properties, keep transform/opacity transitions intact
<ThemeProvider disableTransitionOnChange="background-color 0s, color 0s, border-color 0s">
{children}
</ThemeProvider>
Import paths
The convenience client barrel remains available:
import { useTheme, useThemeValue, ThemedImage } from "@wrksz/themes/client";
Fine-grained client subpaths are also available for consumers who prefer direct public modules:
import { useTheme } from "@wrksz/themes/client/use-theme";
import { useThemeValue } from "@wrksz/themes/client/use-theme-value";
import { useThemeEffect } from "@wrksz/themes/client/use-theme-effect";
import { useHydrated } from "@wrksz/themes/client/use-hydrated";
import { ThemedImage } from "@wrksz/themes/client/themed-image";
import { ClientThemeProvider } from "@wrksz/themes/client/provider";
import { ClientThemeProvider as ExtendedClientThemeProvider } from "@wrksz/themes/client/extended-provider";
import { createThemes } from "@wrksz/themes/client/create-themes";
import { createThemes as createNextThemes } from "@wrksz/themes/next/create-themes";
| Import | Use for |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------- |
| @wrksz/themes/next | ThemeProvider, getTheme in Next.js (recommended) |
| @wrksz/themes/client | useTheme, useThemeValue, useThemeEffect, createThemes, ThemedImage, ClientThemeProvider |
| @wrksz/themes/client/use-theme | Direct useTheme import |
| @wrksz/themes/client/use-theme-value | Direct useThemeValue import |
| @wrksz/themes/client/use-theme-effect | Direct useThemeEffect import |
| @wrksz/themes/client/use-hydrated | Direct useHydrated import |
| @wrksz/themes/client/themed-image | Direct ThemedImage import |
| @wrksz/themes/client/provider | Direct ClientThemeProvider import |
| @wrksz/themes/client/extended-provider | Opt-in ClientThemeProvider with synchronization, mapping, and ShadowRoot support |
| @wrksz/themes/client/create-themes | Direct createThemes import |
| @wrksz/themes/next/create-themes | Typed factory that also returns NextThemeProvider and ThemeScript |
| @wrksz/themes/next/extended | Opt-in Next.js ThemeProvider with synchronization and system mapping |
| @wrksz/themes | Client-safe ThemeProvider alias and createThemes for framework-neutral React usage |
| @wrksz/themes/script | Server-safe ThemeScript for non-Next SSR frameworks |
AI agents
The npm package includes AGENTS.md with import guidance and migration checks. The agent guide adds examples and troubleshooting, with a plain Markdown version and a documentation index.
Ask your agent to read node_modules/@wrksz/themes/AGENTS.md or the guide URL. Some agents skip instructions inside dependencies.
License
MIT