@wrksz/themes
v0.7.7
Published
A modern, fully-featured theme management library for Next.js
Downloads
2,571
Readme
@wrksz/themes
Modern theme management for Next.js 16+ and React 19+. Near drop-in replacement for next-themes - fixes every known bug and adds missing features. Migrating requires changing one import line.
bun add @wrksz/themes
# or
npm install @wrksz/themesWhy not next-themes?
| | next-themes | @wrksz/themes |
|---|:---:|:---:|
| React 19 script warning | ❌ | ✅ useServerInsertedHTML |
| __name minification bug | ❌ | ✅ |
| Stale theme with React 19 cacheComponents | ❌ | ✅ useSyncExternalStore |
| Multi-class theme removal leaving stale classes | ❌ | ✅ |
| Nested providers | ❌ | ✅ per-instance store |
| sessionStorage support | ❌ | ✅ |
| cookie storage (zero-flash SSR) | ❌ | ✅ |
| Disable storage | ❌ | ✅ storage="none" |
| meta theme-color support | ❌ | ✅ themeColor prop |
| Server-provided theme | ❌ | ✅ initialTheme prop |
| disableTransitionOnChange per property | ❌ | ✅ pass a CSS string |
| Read theme outside React | ❌ | ✅ getTheme() helper |
| Generic types | ❌ | ✅ useTheme<AppTheme>() |
| Zero runtime dependencies | ✅ | ✅ |
Table of Contents
Setup
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 <html> to prevent hydration mismatches.
// 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:
ThemeProviderfrom@wrksz/themes/nextis an async Server Component. Use it directly inlayout.tsx- it cannot be wrapped in a"use client"component. For nested providers inside Client Components, useClientThemeProvider.
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 SSR theme flash. The provider reads the cookie server-side automatically - no boilerplate required:
// 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:
/* ❌ causes flash when system pref differs from stored theme */
@media (prefers-color-scheme: dark) {
:root:not(.light) { --bg: #09090b; }
}
/* ✅ */
:root { --bg: #ffffff; }
:root.dark { --bg: #09090b; }Cookie storage does not support cross-tab theme sync. Use
localStoragewithinitialThemeif you need it.
API
ThemeProvider
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| themes | string[] | ["light", "dark"] | Available themes |
| defaultTheme | string | "system" | Theme used when no preference is stored |
| forcedTheme | string | - | Force a specific theme, ignoring user preference |
| initialTheme | string | - | Server-provided theme that overrides storage on mount. User can still call setTheme to change it |
| enableSystem | boolean | true | Detect system preference via prefers-color-scheme |
| enableColorScheme | boolean | true | Set native color-scheme CSS property |
| attribute | string \| string[] | "class" | HTML attribute(s) to set on target element ("class", "data-theme", etc.) |
| 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" \| "none" | "localStorage" | Where to persist the theme. "cookie" reads/writes document.cookie and with @wrksz/themes/next also reads server-side for zero-flash SSR |
| disableTransitionOnChange | boolean \| string | false | Suppress CSS transitions when switching themes. true disables all. Pass a CSS transition value (e.g. "background-color 0s, color 0s") to suppress only specific properties |
| 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 |
| onThemeChange | (theme: string) => void | - | Called when theme changes. Receives the selected value (may be "system"). When system preference changes while theme is "system", fires with the resolved value |
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") => voidgetTheme
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>;| Option | Type | Default | Description |
|--------|------|---------|-------------|
| storageKey | string | "theme" | Cookie name to read from |
| defaultTheme | string | "system" | Returned when no valid theme is found |
| themes | string[] | - | When provided, stored values not in the list fall back to defaultTheme |
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 /> });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 <html>, 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
Initialize from a server-side source (database, session) - overrides stored value 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>
);
}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
| Import | Use for |
|--------|---------|
| @wrksz/themes/next | ThemeProvider, getTheme in Next.js (recommended) |
| @wrksz/themes/client | useTheme, useThemeValue, ThemedImage, ClientThemeProvider |
| @wrksz/themes | ThemeProvider for non-Next.js frameworks |
License
MIT
