@wrksz/themes 0.2.0 → 0.2.1

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,193 @@
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
+ | `themeColor` | `string \| Record<string, string>` | - | Update `<meta name="theme-color">` on theme change |
70
+ | `nonce` | `string` | - | CSP nonce for the inline script |
71
+ | `onThemeChange` | `(theme: string) => void` | - | Called whenever the resolved theme changes |
72
+
73
+ ### `useTheme`
74
+
75
+ ```tsx
76
+ const {
77
+ theme, // Current theme - may be "system"
78
+ resolvedTheme, // Actual theme - never "system"
79
+ systemTheme, // System preference: "light" | "dark" | undefined
80
+ forcedTheme, // Forced theme if set
81
+ themes, // Available themes
82
+ setTheme, // Set theme
83
+ } = useTheme();
84
+ ```
85
+
86
+ Supports generics for full type safety:
87
+
88
+ ```tsx
89
+ type AppTheme = "light" | "dark" | "high-contrast";
90
+
91
+ const { theme, setTheme } = useTheme<AppTheme>();
92
+ // theme: AppTheme | "system" | undefined
93
+ // setTheme: (theme: AppTheme | "system") => void
94
+ ```
95
+
96
+ ## Examples
97
+
98
+ ### Custom themes with Tailwind
99
+
100
+ ```tsx
101
+ <ThemeProvider
102
+ themes={["light", "dark", "high-contrast"]}
103
+ attribute="class"
104
+ >
105
+ {children}
106
+ </ThemeProvider>
107
+ ```
108
+
109
+ ### Data attribute instead of class
110
+
111
+ ```tsx
112
+ <ThemeProvider attribute="data-theme">
113
+ {children}
114
+ </ThemeProvider>
115
+ ```
116
+
117
+ ```css
118
+ [data-theme="dark"] { --bg: #000; }
119
+ [data-theme="light"] { --bg: #fff; }
120
+ ```
121
+
122
+ ### Custom attribute values
123
+
124
+ ```tsx
125
+ <ThemeProvider
126
+ themes={["light", "dark"]}
127
+ attribute="data-mode"
128
+ value={{ light: "light-mode", dark: "dark-mode" }}
129
+ >
130
+ {children}
131
+ </ThemeProvider>
132
+ ```
133
+
134
+ ### Meta theme-color (Safari / PWA)
135
+
136
+ ```tsx
137
+ <ThemeProvider
138
+ themeColor={{ light: "#ffffff", dark: "#0a0a0a" }}
139
+ >
140
+ {children}
141
+ </ThemeProvider>
142
+ ```
143
+
144
+ Works with CSS variables too:
145
+
146
+ ```tsx
147
+ <ThemeProvider themeColor="var(--color-background)">
148
+ {children}
149
+ </ThemeProvider>
150
+ ```
151
+
152
+ ### Disable storage
153
+
154
+ ```tsx
155
+ // No persistence - always uses defaultTheme or system preference
156
+ <ThemeProvider storage="none" defaultTheme="dark">
157
+ {children}
158
+ </ThemeProvider>
159
+ ```
160
+
161
+ ### Forced theme per page
162
+
163
+ ```tsx
164
+ // app/dashboard/layout.tsx
165
+ <ThemeProvider forcedTheme="dark">
166
+ {children}
167
+ </ThemeProvider>
168
+ ```
169
+
170
+ ### Class on body instead of html
171
+
172
+ ```tsx
173
+ <ThemeProvider target="body">
174
+ {children}
175
+ </ThemeProvider>
176
+ ```
177
+
178
+ ## Why not `next-themes`?
179
+
180
+ | Issue | next-themes | @wrksz/themes |
181
+ |-------|-------------|---------------|
182
+ | React 19 script warning | Yes | Fixed (RSC split) |
183
+ | `__name` minification bug | Yes | Fixed |
184
+ | React 19 Activity/cacheComponents stale theme | Yes | Fixed (`useSyncExternalStore`) |
185
+ | `sessionStorage` support | No | Yes |
186
+ | Disable storage | No | Yes (`storage: "none"`) |
187
+ | `meta theme-color` support | No | Yes (`themeColor` prop) |
188
+ | Generic types | No | Yes (`useTheme<AppTheme>()`) |
189
+ | Zero runtime dependencies | Yes | Yes |
190
+
191
+ ## License
192
+
193
+ 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,8 @@ 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;
34
38
  };
35
39
  type ThemeContextValue<Themes extends string = DefaultTheme> = {
36
40
  /** Current theme (may be "system") */
@@ -48,5 +52,5 @@ type ThemeContextValue<Themes extends string = DefaultTheme> = {
48
52
  };
49
53
  declare function useTheme<Themes extends string = DefaultTheme>(): ThemeContextValue<Themes>;
50
54
  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 };
55
+ declare function ThemeProvider<Themes extends string = DefaultTheme>({ children, themes, forcedTheme, enableSystem, defaultTheme, attribute, value: valueMap, target, disableTransitionOnChange, storage, storageKey, enableColorScheme, nonce, onThemeChange, themeColor }: ThemeProviderProps<Themes>): ReactElement;
56
+ export { useTheme, ValueObject, ThemeProviderProps, ThemeProvider, ThemeContextValue, ThemeColor, StorageType, DefaultTheme, Attribute };
package/dist/index.js CHANGED
@@ -10,10 +10,63 @@ 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 state = { theme: undefined, systemTheme: undefined };
17
+ var listeners = new Set;
18
+ function emit() {
19
+ for (const listener of listeners)
20
+ listener();
21
+ }
22
+ var SERVER_SNAPSHOT = { theme: undefined, systemTheme: undefined };
23
+ var themeStore = {
24
+ subscribe(listener) {
25
+ listeners.add(listener);
26
+ return () => {
27
+ listeners.delete(listener);
28
+ };
29
+ },
30
+ getSnapshot() {
31
+ return state;
32
+ },
33
+ getServerSnapshot() {
34
+ return SERVER_SNAPSHOT;
35
+ },
36
+ setTheme(theme) {
37
+ if (state.theme === theme)
38
+ return;
39
+ state = { ...state, theme };
40
+ emit();
41
+ },
42
+ setSystemTheme(systemTheme) {
43
+ if (state.systemTheme === systemTheme)
44
+ return;
45
+ state = { ...state, systemTheme };
46
+ emit();
47
+ }
48
+ };
49
+
50
+ // src/client-provider.tsx
14
51
  import { jsxDEV } from "react/jsx-dev-runtime";
15
52
 
16
53
  var DEFAULT_THEMES = ["light", "dark"];
54
+ function resolveThemeColor(themeColor, resolved) {
55
+ if (typeof themeColor === "string")
56
+ return themeColor;
57
+ return themeColor[resolved];
58
+ }
59
+ function updateMetaThemeColor(color) {
60
+ if (!color)
61
+ return;
62
+ let meta = document.querySelector('meta[name="theme-color"]');
63
+ if (!meta) {
64
+ meta = document.createElement("meta");
65
+ meta.name = "theme-color";
66
+ document.head.appendChild(meta);
67
+ }
68
+ meta.content = color;
69
+ }
17
70
  function ClientThemeProvider({
18
71
  children,
19
72
  themes = DEFAULT_THEMES,
@@ -27,11 +80,11 @@ function ClientThemeProvider({
27
80
  storage = "localStorage",
28
81
  storageKey = "theme",
29
82
  enableColorScheme = true,
83
+ themeColor,
30
84
  onThemeChange
31
85
  }) {
32
86
  const resolvedDefault = defaultTheme ?? (enableSystem ? "system" : "light");
33
- const [theme, setThemeState] = useState(undefined);
34
- const [systemTheme, setSystemTheme] = useState(undefined);
87
+ const { theme, systemTheme } = useSyncExternalStore(themeStore.subscribe, themeStore.getSnapshot, themeStore.getServerSnapshot);
35
88
  const resolvedTheme = forcedTheme ?? (theme === "system" || theme === undefined ? systemTheme : theme);
36
89
  const onThemeChangeRef = useRef(onThemeChange);
37
90
  useEffect(() => {
@@ -68,10 +121,21 @@ function ClientThemeProvider({
68
121
  if (enableColorScheme && (resolved === "light" || resolved === "dark")) {
69
122
  el.style.colorScheme = resolved;
70
123
  }
71
- }, [attribute, disableTransitionOnChange, enableColorScheme, getTargetEl, themes, valueMap]);
124
+ if (themeColor) {
125
+ updateMetaThemeColor(resolveThemeColor(themeColor, resolved));
126
+ }
127
+ }, [
128
+ attribute,
129
+ disableTransitionOnChange,
130
+ enableColorScheme,
131
+ getTargetEl,
132
+ themes,
133
+ valueMap,
134
+ themeColor
135
+ ]);
72
136
  useEffect(() => {
73
137
  if (forcedTheme) {
74
- setThemeState(forcedTheme);
138
+ themeStore.setTheme(forcedTheme);
75
139
  return;
76
140
  }
77
141
  let stored = null;
@@ -82,25 +146,25 @@ function ClientThemeProvider({
82
146
  }
83
147
  } catch {}
84
148
  const initial = stored && themes.includes(stored) ? stored : resolvedDefault;
85
- setThemeState(initial);
149
+ themeStore.setTheme(initial);
86
150
  }, [forcedTheme, resolvedDefault, storage, storageKey, themes]);
87
151
  useEffect(() => {
88
152
  if (!enableSystem)
89
153
  return;
90
154
  const mq = window.matchMedia("(prefers-color-scheme: dark)");
91
- const sys = mq.matches ? "dark" : "light";
92
- setSystemTheme(sys);
155
+ themeStore.setSystemTheme(mq.matches ? "dark" : "light");
93
156
  const handler = (e) => {
94
157
  const next = e.matches ? "dark" : "light";
95
- setSystemTheme(next);
96
- if (theme === "system" || theme === undefined) {
158
+ themeStore.setSystemTheme(next);
159
+ const current = themeStore.getSnapshot().theme;
160
+ if (current === "system" || current === undefined) {
97
161
  applyToDom(next);
98
162
  onThemeChangeRef.current?.(next);
99
163
  }
100
164
  };
101
165
  mq.addEventListener("change", handler);
102
166
  return () => mq.removeEventListener("change", handler);
103
- }, [enableSystem, theme, applyToDom]);
167
+ }, [enableSystem, applyToDom]);
104
168
  useEffect(() => {
105
169
  if (storage === "none")
106
170
  return;
@@ -109,20 +173,21 @@ function ClientThemeProvider({
109
173
  return;
110
174
  if (themes.includes(e.newValue)) {
111
175
  const newTheme = e.newValue;
112
- const resolved = newTheme === "system" ? systemTheme ?? "light" : newTheme;
113
- setThemeState(newTheme);
176
+ const resolved = newTheme === "system" ? themeStore.getSnapshot().systemTheme ?? "light" : newTheme;
177
+ themeStore.setTheme(newTheme);
114
178
  applyToDom(resolved);
115
179
  }
116
180
  };
117
181
  window.addEventListener("storage", handler);
118
182
  return () => window.removeEventListener("storage", handler);
119
- }, [storage, storageKey, themes, systemTheme, applyToDom]);
183
+ }, [storage, storageKey, themes, applyToDom]);
120
184
  const setTheme = useCallback((next) => {
121
185
  if (forcedTheme)
122
186
  return;
123
- const newTheme = typeof next === "function" ? next(theme) : next;
124
- const resolved = newTheme === "system" ? systemTheme ?? "light" : newTheme;
125
- setThemeState(newTheme);
187
+ const current = themeStore.getSnapshot().theme;
188
+ const newTheme = typeof next === "function" ? next(current) : next;
189
+ const resolved = newTheme === "system" ? themeStore.getSnapshot().systemTheme ?? "light" : newTheme;
190
+ themeStore.setTheme(newTheme);
126
191
  applyToDom(resolved);
127
192
  onThemeChangeRef.current?.(resolved);
128
193
  try {
@@ -131,7 +196,7 @@ function ClientThemeProvider({
131
196
  store.setItem(storageKey, newTheme);
132
197
  }
133
198
  } catch {}
134
- }, [applyToDom, forcedTheme, storage, storageKey, systemTheme, theme]);
199
+ }, [applyToDom, forcedTheme, storage, storageKey]);
135
200
  const contextValue = {
136
201
  theme: forcedTheme ?? theme,
137
202
  resolvedTheme,
@@ -147,7 +212,7 @@ function ClientThemeProvider({
147
212
  }
148
213
 
149
214
  // src/script.ts
150
- function themeScript(storageKey, attribute, defaultTheme, enableSystem, enableColorScheme, forcedTheme, themes, value, target, storage) {
215
+ function themeScript(storageKey, attribute, defaultTheme, enableSystem, enableColorScheme, forcedTheme, themes, value, target, storage, themeColors) {
151
216
  let theme;
152
217
  if (forcedTheme) {
153
218
  theme = forcedTheme;
@@ -181,6 +246,18 @@ function themeScript(storageKey, attribute, defaultTheme, enableSystem, enableCo
181
246
  if (enableColorScheme && (theme === "light" || theme === "dark")) {
182
247
  el.style.colorScheme = theme;
183
248
  }
249
+ if (themeColors) {
250
+ const color = typeof themeColors === "string" ? themeColors : themeColors[theme];
251
+ if (color) {
252
+ let meta = document.querySelector('meta[name="theme-color"]');
253
+ if (!meta) {
254
+ meta = document.createElement("meta");
255
+ meta.setAttribute("name", "theme-color");
256
+ document.head.appendChild(meta);
257
+ }
258
+ meta.setAttribute("content", color);
259
+ }
260
+ }
184
261
  }
185
262
  function getScript(config) {
186
263
  const fn = themeScript.toString().replace(/\s*__name\s*\([^)]*\)\s*;?\s*/g, "");
@@ -194,7 +271,8 @@ function getScript(config) {
194
271
  JSON.stringify(config.themes),
195
272
  JSON.stringify(config.value ?? null),
196
273
  JSON.stringify(config.target),
197
- JSON.stringify(config.storage)
274
+ JSON.stringify(config.storage),
275
+ JSON.stringify(config.themeColors ?? null)
198
276
  ].join(",");
199
277
  return `(${fn})(${args})`;
200
278
  }
@@ -216,7 +294,8 @@ function ThemeProvider({
216
294
  storageKey = "theme",
217
295
  enableColorScheme = true,
218
296
  nonce,
219
- onThemeChange
297
+ onThemeChange,
298
+ themeColor
220
299
  }) {
221
300
  const resolvedDefault = defaultTheme ?? (enableSystem ? "system" : "light");
222
301
  return /* @__PURE__ */ jsxDEV2(Fragment, {
@@ -233,7 +312,8 @@ function ThemeProvider({
233
312
  themes,
234
313
  value: valueMap,
235
314
  target,
236
- storage
315
+ storage,
316
+ themeColors: themeColor
237
317
  })
238
318
  },
239
319
  nonce
@@ -250,6 +330,7 @@ function ThemeProvider({
250
330
  storage,
251
331
  storageKey,
252
332
  enableColorScheme,
333
+ themeColor,
253
334
  onThemeChange,
254
335
  children
255
336
  }, 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.2.1",
4
4
  "description": "A modern, fully-featured theme management library for Next.js",
5
5
  "repository": {
6
6
  "type": "git",