@wrksz/themes 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1 +1,194 @@
1
- # themes
1
+ <img src=".github/images/banner.png" alt="@wrksz/themes" width="100%" />
2
+
3
+ # @wrksz/themes
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@wrksz/themes)](https://www.npmjs.com/package/@wrksz/themes)
6
+
7
+ Modern theme management for Next.js 16+ and React 19+. Drop-in replacement for `next-themes` - fixes every known bug and adds missing features.
8
+
9
+ ```bash
10
+ bun add @wrksz/themes
11
+ # or
12
+ npm install @wrksz/themes
13
+ ```
14
+
15
+ ## Setup
16
+
17
+ Add the provider to your root layout. Add `suppressHydrationWarning` to `<html>` to prevent hydration warnings caused by the inline theme script running before React hydrates.
18
+
19
+ ```tsx
20
+ // app/layout.tsx
21
+ import { ThemeProvider } from "@wrksz/themes";
22
+
23
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
24
+ return (
25
+ <html lang="en" suppressHydrationWarning>
26
+ <body>
27
+ <ThemeProvider>{children}</ThemeProvider>
28
+ </body>
29
+ </html>
30
+ );
31
+ }
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```tsx
37
+ "use client";
38
+
39
+ import { useTheme } from "@wrksz/themes";
40
+
41
+ export function ThemeToggle() {
42
+ const { theme, setTheme } = useTheme();
43
+
44
+ return (
45
+ <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
46
+ Toggle theme
47
+ </button>
48
+ );
49
+ }
50
+ ```
51
+
52
+ ## API
53
+
54
+ ### `ThemeProvider`
55
+
56
+ | Prop | Type | Default | Description |
57
+ |------|------|---------|-------------|
58
+ | `themes` | `string[]` | `["light", "dark"]` | Available themes |
59
+ | `defaultTheme` | `string` | `"system"` | Theme used when no preference is stored |
60
+ | `forcedTheme` | `string` | - | Force a specific theme, ignoring user preference |
61
+ | `enableSystem` | `boolean` | `true` | Detect system preference via `prefers-color-scheme` |
62
+ | `enableColorScheme` | `boolean` | `true` | Set native `color-scheme` CSS property |
63
+ | `attribute` | `string \| string[]` | `"class"` | HTML attribute(s) to set on target element (`"class"`, `"data-theme"`, etc.) |
64
+ | `value` | `Record<string, string>` | - | Map theme names to attribute values |
65
+ | `target` | `string` | `"html"` | Element to apply theme to (`"html"`, `"body"`, or a CSS selector) |
66
+ | `storageKey` | `string` | `"theme"` | Key used for storage |
67
+ | `storage` | `"localStorage" \| "sessionStorage" \| "none"` | `"localStorage"` | Where to persist the theme |
68
+ | `disableTransitionOnChange` | `boolean` | `false` | Disable CSS transitions when switching themes |
69
+ | `followSystem` | `boolean` | `false` | Always follow system preference changes, even after `setTheme` was called |
70
+ | `themeColor` | `string \| Record<string, string>` | - | Update `<meta name="theme-color">` on theme change |
71
+ | `nonce` | `string` | - | CSP nonce for the inline script |
72
+ | `onThemeChange` | `(theme: string) => void` | - | Called whenever the resolved theme changes |
73
+
74
+ ### `useTheme`
75
+
76
+ ```tsx
77
+ const {
78
+ theme, // Current theme - may be "system"
79
+ resolvedTheme, // Actual theme - never "system"
80
+ systemTheme, // System preference: "light" | "dark" | undefined
81
+ forcedTheme, // Forced theme if set
82
+ themes, // Available themes
83
+ setTheme, // Set theme
84
+ } = useTheme();
85
+ ```
86
+
87
+ Supports generics for full type safety:
88
+
89
+ ```tsx
90
+ type AppTheme = "light" | "dark" | "high-contrast";
91
+
92
+ const { theme, setTheme } = useTheme<AppTheme>();
93
+ // theme: AppTheme | "system" | undefined
94
+ // setTheme: (theme: AppTheme | "system") => void
95
+ ```
96
+
97
+ ## Examples
98
+
99
+ ### Custom themes with Tailwind
100
+
101
+ ```tsx
102
+ <ThemeProvider
103
+ themes={["light", "dark", "high-contrast"]}
104
+ attribute="class"
105
+ >
106
+ {children}
107
+ </ThemeProvider>
108
+ ```
109
+
110
+ ### Data attribute instead of class
111
+
112
+ ```tsx
113
+ <ThemeProvider attribute="data-theme">
114
+ {children}
115
+ </ThemeProvider>
116
+ ```
117
+
118
+ ```css
119
+ [data-theme="dark"] { --bg: #000; }
120
+ [data-theme="light"] { --bg: #fff; }
121
+ ```
122
+
123
+ ### Custom attribute values
124
+
125
+ ```tsx
126
+ <ThemeProvider
127
+ themes={["light", "dark"]}
128
+ attribute="data-mode"
129
+ value={{ light: "light-mode", dark: "dark-mode" }}
130
+ >
131
+ {children}
132
+ </ThemeProvider>
133
+ ```
134
+
135
+ ### Meta theme-color (Safari / PWA)
136
+
137
+ ```tsx
138
+ <ThemeProvider
139
+ themeColor={{ light: "#ffffff", dark: "#0a0a0a" }}
140
+ >
141
+ {children}
142
+ </ThemeProvider>
143
+ ```
144
+
145
+ Works with CSS variables too:
146
+
147
+ ```tsx
148
+ <ThemeProvider themeColor="var(--color-background)">
149
+ {children}
150
+ </ThemeProvider>
151
+ ```
152
+
153
+ ### Disable storage
154
+
155
+ ```tsx
156
+ // No persistence - always uses defaultTheme or system preference
157
+ <ThemeProvider storage="none" defaultTheme="dark">
158
+ {children}
159
+ </ThemeProvider>
160
+ ```
161
+
162
+ ### Forced theme per page
163
+
164
+ ```tsx
165
+ // app/dashboard/layout.tsx
166
+ <ThemeProvider forcedTheme="dark">
167
+ {children}
168
+ </ThemeProvider>
169
+ ```
170
+
171
+ ### Class on body instead of html
172
+
173
+ ```tsx
174
+ <ThemeProvider target="body">
175
+ {children}
176
+ </ThemeProvider>
177
+ ```
178
+
179
+ ## Why not `next-themes`?
180
+
181
+ | Issue | next-themes | @wrksz/themes |
182
+ |-------|-------------|---------------|
183
+ | React 19 script warning | Yes | Fixed (RSC split) |
184
+ | `__name` minification bug | Yes | Fixed |
185
+ | React 19 Activity/cacheComponents stale theme | Yes | Fixed (`useSyncExternalStore`) |
186
+ | `sessionStorage` support | No | Yes |
187
+ | Disable storage | No | Yes (`storage: "none"`) |
188
+ | `meta theme-color` support | No | Yes (`themeColor` prop) |
189
+ | Generic types | No | Yes (`useTheme<AppTheme>()`) |
190
+ | Zero runtime dependencies | Yes | Yes |
191
+
192
+ ## License
193
+
194
+ MIT
package/dist/index.d.ts CHANGED
@@ -3,6 +3,8 @@ type DefaultTheme = "light" | "dark" | "system";
3
3
  type Attribute = "class" | `data-${string}`;
4
4
  type ValueObject = Record<string, string>;
5
5
  type StorageType = "localStorage" | "sessionStorage" | "none";
6
+ /** Per-theme colors for meta theme-color, or a single string for all themes */
7
+ type ThemeColor = string | Partial<Record<string, string>>;
6
8
  type ThemeProviderProps<Themes extends string = DefaultTheme> = {
7
9
  children: ReactNode;
8
10
  /** All available themes */
@@ -31,6 +33,10 @@ type ThemeProviderProps<Themes extends string = DefaultTheme> = {
31
33
  nonce?: string;
32
34
  /** Called when theme changes */
33
35
  onThemeChange?: (theme: Themes) => void;
36
+ /** Colors for meta theme-color tag, per theme or a single value */
37
+ themeColor?: ThemeColor;
38
+ /** Always follow system preference changes, even after setTheme was called */
39
+ followSystem?: boolean;
34
40
  };
35
41
  type ThemeContextValue<Themes extends string = DefaultTheme> = {
36
42
  /** Current theme (may be "system") */
@@ -48,5 +54,5 @@ type ThemeContextValue<Themes extends string = DefaultTheme> = {
48
54
  };
49
55
  declare function useTheme<Themes extends string = DefaultTheme>(): ThemeContextValue<Themes>;
50
56
  import { ReactElement } from "react";
51
- declare function ThemeProvider<Themes extends string = DefaultTheme>({ children, themes, forcedTheme, enableSystem, defaultTheme, attribute, value: valueMap, target, disableTransitionOnChange, storage, storageKey, enableColorScheme, nonce, onThemeChange }: ThemeProviderProps<Themes>): ReactElement;
52
- export { useTheme, ValueObject, ThemeProviderProps, ThemeProvider, ThemeContextValue, StorageType, DefaultTheme, Attribute };
57
+ declare function ThemeProvider<Themes extends string = DefaultTheme>({ children, themes, forcedTheme, enableSystem, defaultTheme, attribute, value: valueMap, target, disableTransitionOnChange, storage, storageKey, enableColorScheme, nonce, onThemeChange, themeColor, followSystem }: ThemeProviderProps<Themes>): ReactElement;
58
+ export { useTheme, ValueObject, ThemeProviderProps, ThemeProvider, ThemeContextValue, ThemeColor, StorageType, DefaultTheme, Attribute };
package/dist/index.js CHANGED
@@ -10,10 +10,65 @@ function useTheme() {
10
10
  return ctx;
11
11
  }
12
12
  // src/client-provider.tsx
13
- import { useCallback, useEffect, useRef, useState } from "react";
13
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
14
+
15
+ // src/store.ts
16
+ var SERVER_SNAPSHOT = { theme: undefined, systemTheme: undefined };
17
+ function createThemeStore() {
18
+ let state = { theme: undefined, systemTheme: undefined };
19
+ const listeners = new Set;
20
+ function emit() {
21
+ for (const listener of listeners)
22
+ listener();
23
+ }
24
+ return {
25
+ subscribe(listener) {
26
+ listeners.add(listener);
27
+ return () => {
28
+ listeners.delete(listener);
29
+ };
30
+ },
31
+ getSnapshot() {
32
+ return state;
33
+ },
34
+ getServerSnapshot() {
35
+ return SERVER_SNAPSHOT;
36
+ },
37
+ setTheme(theme) {
38
+ if (state.theme === theme)
39
+ return;
40
+ state = { ...state, theme };
41
+ emit();
42
+ },
43
+ setSystemTheme(systemTheme) {
44
+ if (state.systemTheme === systemTheme)
45
+ return;
46
+ state = { ...state, systemTheme };
47
+ emit();
48
+ }
49
+ };
50
+ }
51
+
52
+ // src/client-provider.tsx
14
53
  import { jsxDEV } from "react/jsx-dev-runtime";
15
54
 
16
55
  var DEFAULT_THEMES = ["light", "dark"];
56
+ function resolveThemeColor(themeColor, resolved) {
57
+ if (typeof themeColor === "string")
58
+ return themeColor;
59
+ return themeColor[resolved];
60
+ }
61
+ function updateMetaThemeColor(color) {
62
+ if (!color)
63
+ return;
64
+ let meta = document.querySelector('meta[name="theme-color"]');
65
+ if (!meta) {
66
+ meta = document.createElement("meta");
67
+ meta.name = "theme-color";
68
+ document.head.appendChild(meta);
69
+ }
70
+ meta.content = color;
71
+ }
17
72
  function ClientThemeProvider({
18
73
  children,
19
74
  themes = DEFAULT_THEMES,
@@ -27,11 +82,15 @@ function ClientThemeProvider({
27
82
  storage = "localStorage",
28
83
  storageKey = "theme",
29
84
  enableColorScheme = true,
85
+ themeColor,
86
+ followSystem = false,
30
87
  onThemeChange
31
88
  }) {
32
89
  const resolvedDefault = defaultTheme ?? (enableSystem ? "system" : "light");
33
- const [theme, setThemeState] = useState(undefined);
34
- const [systemTheme, setSystemTheme] = useState(undefined);
90
+ const storeRef = useRef(createThemeStore());
91
+ const store = storeRef.current;
92
+ const { getSnapshot, setTheme: setStoreTheme, setSystemTheme: setStoreSystemTheme } = store;
93
+ const { theme, systemTheme } = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);
35
94
  const resolvedTheme = forcedTheme ?? (theme === "system" || theme === undefined ? systemTheme : theme);
36
95
  const onThemeChangeRef = useRef(onThemeChange);
37
96
  useEffect(() => {
@@ -68,39 +127,81 @@ function ClientThemeProvider({
68
127
  if (enableColorScheme && (resolved === "light" || resolved === "dark")) {
69
128
  el.style.colorScheme = resolved;
70
129
  }
71
- }, [attribute, disableTransitionOnChange, enableColorScheme, getTargetEl, themes, valueMap]);
130
+ if (themeColor) {
131
+ updateMetaThemeColor(resolveThemeColor(themeColor, resolved));
132
+ }
133
+ }, [
134
+ attribute,
135
+ disableTransitionOnChange,
136
+ enableColorScheme,
137
+ getTargetEl,
138
+ themes,
139
+ valueMap,
140
+ themeColor
141
+ ]);
72
142
  useEffect(() => {
143
+ const mq = enableSystem ? window.matchMedia("(prefers-color-scheme: dark)") : null;
144
+ const sys = mq ? mq.matches ? "dark" : "light" : undefined;
145
+ if (sys)
146
+ setStoreSystemTheme(sys);
73
147
  if (forcedTheme) {
74
- setThemeState(forcedTheme);
75
- return;
148
+ setStoreTheme(forcedTheme);
149
+ applyToDom(forcedTheme);
150
+ } else {
151
+ let stored = null;
152
+ try {
153
+ if (storage !== "none") {
154
+ const s = storage === "localStorage" ? localStorage : sessionStorage;
155
+ stored = s.getItem(storageKey);
156
+ }
157
+ } catch {}
158
+ const initial = stored && themes.includes(stored) ? stored : resolvedDefault;
159
+ setStoreTheme(initial);
160
+ applyToDom(initial === "system" ? sys ?? "light" : initial);
76
161
  }
77
- let stored = null;
78
- try {
79
- if (storage !== "none") {
80
- const store = storage === "localStorage" ? localStorage : sessionStorage;
81
- stored = store.getItem(storageKey);
82
- }
83
- } catch {}
84
- const initial = stored && themes.includes(stored) ? stored : resolvedDefault;
85
- setThemeState(initial);
86
- }, [forcedTheme, resolvedDefault, storage, storageKey, themes]);
87
- useEffect(() => {
88
- if (!enableSystem)
162
+ if (!mq)
89
163
  return;
90
- const mq = window.matchMedia("(prefers-color-scheme: dark)");
91
- const sys = mq.matches ? "dark" : "light";
92
- setSystemTheme(sys);
93
164
  const handler = (e) => {
94
165
  const next = e.matches ? "dark" : "light";
95
- setSystemTheme(next);
96
- if (theme === "system" || theme === undefined) {
166
+ setStoreSystemTheme(next);
167
+ const current = getSnapshot().theme;
168
+ if (current === "system" || current === undefined || followSystem) {
169
+ if (followSystem) {
170
+ setStoreTheme("system");
171
+ }
97
172
  applyToDom(next);
98
173
  onThemeChangeRef.current?.(next);
99
174
  }
100
175
  };
101
176
  mq.addEventListener("change", handler);
102
177
  return () => mq.removeEventListener("change", handler);
103
- }, [enableSystem, theme, applyToDom]);
178
+ }, [
179
+ forcedTheme,
180
+ resolvedDefault,
181
+ storage,
182
+ storageKey,
183
+ themes,
184
+ enableSystem,
185
+ followSystem,
186
+ applyToDom,
187
+ getSnapshot,
188
+ setStoreTheme,
189
+ setStoreSystemTheme
190
+ ]);
191
+ useEffect(() => {
192
+ const handler = () => {
193
+ const { theme: theme2, systemTheme: systemTheme2 } = getSnapshot();
194
+ const resolved = forcedTheme ?? (theme2 === "system" || theme2 === undefined ? systemTheme2 : theme2);
195
+ if (resolved)
196
+ applyToDom(resolved);
197
+ };
198
+ window.addEventListener("pageshow", handler);
199
+ window.addEventListener("popstate", handler);
200
+ return () => {
201
+ window.removeEventListener("pageshow", handler);
202
+ window.removeEventListener("popstate", handler);
203
+ };
204
+ }, [applyToDom, forcedTheme, getSnapshot]);
104
205
  useEffect(() => {
105
206
  if (storage === "none")
106
207
  return;
@@ -109,29 +210,30 @@ function ClientThemeProvider({
109
210
  return;
110
211
  if (themes.includes(e.newValue)) {
111
212
  const newTheme = e.newValue;
112
- const resolved = newTheme === "system" ? systemTheme ?? "light" : newTheme;
113
- setThemeState(newTheme);
213
+ const resolved = newTheme === "system" ? getSnapshot().systemTheme ?? "light" : newTheme;
214
+ setStoreTheme(newTheme);
114
215
  applyToDom(resolved);
115
216
  }
116
217
  };
117
218
  window.addEventListener("storage", handler);
118
219
  return () => window.removeEventListener("storage", handler);
119
- }, [storage, storageKey, themes, systemTheme, applyToDom]);
220
+ }, [storage, storageKey, themes, applyToDom, getSnapshot, setStoreTheme]);
120
221
  const setTheme = useCallback((next) => {
121
222
  if (forcedTheme)
122
223
  return;
123
- const newTheme = typeof next === "function" ? next(theme) : next;
124
- const resolved = newTheme === "system" ? systemTheme ?? "light" : newTheme;
125
- setThemeState(newTheme);
224
+ const current = getSnapshot().theme;
225
+ const newTheme = typeof next === "function" ? next(current) : next;
226
+ const resolved = newTheme === "system" ? getSnapshot().systemTheme ?? "light" : newTheme;
227
+ setStoreTheme(newTheme);
126
228
  applyToDom(resolved);
127
229
  onThemeChangeRef.current?.(resolved);
128
230
  try {
129
231
  if (storage !== "none") {
130
- const store = storage === "localStorage" ? localStorage : sessionStorage;
131
- store.setItem(storageKey, newTheme);
232
+ const store2 = storage === "localStorage" ? localStorage : sessionStorage;
233
+ store2.setItem(storageKey, newTheme);
132
234
  }
133
235
  } catch {}
134
- }, [applyToDom, forcedTheme, storage, storageKey, systemTheme, theme]);
236
+ }, [applyToDom, forcedTheme, storage, storageKey, getSnapshot, setStoreTheme]);
135
237
  const contextValue = {
136
238
  theme: forcedTheme ?? theme,
137
239
  resolvedTheme,
@@ -147,7 +249,7 @@ function ClientThemeProvider({
147
249
  }
148
250
 
149
251
  // src/script.ts
150
- function themeScript(storageKey, attribute, defaultTheme, enableSystem, enableColorScheme, forcedTheme, themes, value, target, storage) {
252
+ function themeScript(storageKey, attribute, defaultTheme, enableSystem, enableColorScheme, forcedTheme, themes, value, target, storage, themeColors) {
151
253
  let theme;
152
254
  if (forcedTheme) {
153
255
  theme = forcedTheme;
@@ -181,6 +283,18 @@ function themeScript(storageKey, attribute, defaultTheme, enableSystem, enableCo
181
283
  if (enableColorScheme && (theme === "light" || theme === "dark")) {
182
284
  el.style.colorScheme = theme;
183
285
  }
286
+ if (themeColors) {
287
+ const color = typeof themeColors === "string" ? themeColors : themeColors[theme];
288
+ if (color) {
289
+ let meta = document.querySelector('meta[name="theme-color"]');
290
+ if (!meta) {
291
+ meta = document.createElement("meta");
292
+ meta.setAttribute("name", "theme-color");
293
+ document.head.appendChild(meta);
294
+ }
295
+ meta.setAttribute("content", color);
296
+ }
297
+ }
184
298
  }
185
299
  function getScript(config) {
186
300
  const fn = themeScript.toString().replace(/\s*__name\s*\([^)]*\)\s*;?\s*/g, "");
@@ -194,7 +308,8 @@ function getScript(config) {
194
308
  JSON.stringify(config.themes),
195
309
  JSON.stringify(config.value ?? null),
196
310
  JSON.stringify(config.target),
197
- JSON.stringify(config.storage)
311
+ JSON.stringify(config.storage),
312
+ JSON.stringify(config.themeColors ?? null)
198
313
  ].join(",");
199
314
  return `(${fn})(${args})`;
200
315
  }
@@ -216,7 +331,9 @@ function ThemeProvider({
216
331
  storageKey = "theme",
217
332
  enableColorScheme = true,
218
333
  nonce,
219
- onThemeChange
334
+ onThemeChange,
335
+ themeColor,
336
+ followSystem = false
220
337
  }) {
221
338
  const resolvedDefault = defaultTheme ?? (enableSystem ? "system" : "light");
222
339
  return /* @__PURE__ */ jsxDEV2(Fragment, {
@@ -233,7 +350,8 @@ function ThemeProvider({
233
350
  themes,
234
351
  value: valueMap,
235
352
  target,
236
- storage
353
+ storage,
354
+ themeColors: themeColor
237
355
  })
238
356
  },
239
357
  nonce
@@ -250,6 +368,8 @@ function ThemeProvider({
250
368
  storage,
251
369
  storageKey,
252
370
  enableColorScheme,
371
+ themeColor,
372
+ followSystem,
253
373
  onThemeChange,
254
374
  children
255
375
  }, undefined, false, undefined, this)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrksz/themes",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A modern, fully-featured theme management library for Next.js",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,6 +17,7 @@
17
17
  "lint": "biome check src",
18
18
  "lint:fix": "biome check --write src",
19
19
  "format": "biome format --write src",
20
+ "test": "bun test",
20
21
  "prepare": "[ \"$CI\" = \"true\" ] || lefthook install"
21
22
  },
22
23
  "devDependencies": {
@@ -25,6 +26,7 @@
25
26
  "@types/react": "^19.2.14",
26
27
  "@types/react-dom": "^19.2.3",
27
28
  "bunup": "^0.16.31",
29
+ "happy-dom": "^20.8.4",
28
30
  "lefthook": "^2.1.4",
29
31
  "react": "^19.2.4",
30
32
  "react-dom": "^19.2.4",