@wrksz/themes 0.3.0 → 0.4.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
@@ -58,6 +58,7 @@ export function ThemeToggle() {
58
58
  | `themes` | `string[]` | `["light", "dark"]` | Available themes |
59
59
  | `defaultTheme` | `string` | `"system"` | Theme used when no preference is stored |
60
60
  | `forcedTheme` | `string` | - | Force a specific theme, ignoring user preference |
61
+ | `initialTheme` | `string` | - | Server-provided theme that overrides storage on mount. User can still call `setTheme` to change it |
61
62
  | `enableSystem` | `boolean` | `true` | Detect system preference via `prefers-color-scheme` |
62
63
  | `enableColorScheme` | `boolean` | `true` | Set native `color-scheme` CSS property |
63
64
  | `attribute` | `string \| string[]` | `"class"` | HTML attribute(s) to set on target element (`"class"`, `"data-theme"`, etc.) |
@@ -66,9 +67,9 @@ export function ThemeToggle() {
66
67
  | `storageKey` | `string` | `"theme"` | Key used for storage |
67
68
  | `storage` | `"localStorage" \| "sessionStorage" \| "none"` | `"localStorage"` | Where to persist the theme |
68
69
  | `disableTransitionOnChange` | `boolean` | `false` | Disable CSS transitions when switching themes |
69
- | `followSystem` | `boolean` | `false` | Always follow system preference changes, even after `setTheme` was called |
70
+ | `followSystem` | `boolean` | `false` | Always follow system preference changes, even after `setTheme` was called. Also ignores stored value on mount in favor of current system preference |
70
71
  | `themeColor` | `string \| Record<string, string>` | - | Update `<meta name="theme-color">` on theme change |
71
- | `nonce` | `string` | - | CSP nonce for the inline script |
72
+ | `nonce` | `string` | - | CSP nonce for the inline script (`ThemeProvider` only - `ClientThemeProvider` renders no script) |
72
73
  | `onThemeChange` | `(theme: string) => void` | - | Called whenever the resolved theme changes |
73
74
 
74
75
  ### `useTheme`
@@ -132,16 +133,27 @@ const { theme, setTheme } = useTheme<AppTheme>();
132
133
  </ThemeProvider>
133
134
  ```
134
135
 
135
- ### Meta theme-color (Safari / PWA)
136
+ ### Multiple classes per theme
137
+
138
+ Map a theme to multiple CSS classes by using a space-separated value:
136
139
 
137
140
  ```tsx
138
141
  <ThemeProvider
139
- themeColor={{ light: "#ffffff", dark: "#0a0a0a" }}
142
+ themes={["light", "dark", "dim"]}
143
+ value={{ light: "light", dark: "dark high-contrast", dim: "dark dim" }}
140
144
  >
141
145
  {children}
142
146
  </ThemeProvider>
143
147
  ```
144
148
 
149
+ ### Meta theme-color (Safari / PWA)
150
+
151
+ ```tsx
152
+ <ThemeProvider themeColor={{ light: "#ffffff", dark: "#0a0a0a" }}>
153
+ {children}
154
+ </ThemeProvider>
155
+ ```
156
+
145
157
  Works with CSS variables too:
146
158
 
147
159
  ```tsx
@@ -168,6 +180,153 @@ Works with CSS variables too:
168
180
  </ThemeProvider>
169
181
  ```
170
182
 
183
+ ### Different theme per section (scoped theming)
184
+
185
+ Apply the theme to a specific element instead of `<html>` using the `target` prop. This lets different sections of your app have independent themes simultaneously.
186
+
187
+ ```tsx
188
+ // app/landing/layout.tsx
189
+ export default function LandingLayout({ children }) {
190
+ return (
191
+ <ThemeProvider forcedTheme="dark" target="#landing-root" storage="none">
192
+ <div id="landing-root">{children}</div>
193
+ </ThemeProvider>
194
+ );
195
+ }
196
+
197
+ // app/dashboard/layout.tsx
198
+ export default function DashboardLayout({ children }) {
199
+ return (
200
+ <ThemeProvider forcedTheme="light" target="#dashboard-root" storage="none">
201
+ <div id="dashboard-root">{children}</div>
202
+ </ThemeProvider>
203
+ );
204
+ }
205
+ ```
206
+
207
+ ```css
208
+ /* scope your CSS variables to the target element */
209
+ #landing-root { --bg: #0a0a0a; --fg: #fafafa; }
210
+ #dashboard-root { --bg: #ffffff; --fg: #0a0a0a; }
211
+ ```
212
+
213
+ Use `storage="none"` when the theme is forced - there's nothing to persist.
214
+
215
+ ### Server-provided theme
216
+
217
+ Use `initialTheme` to initialize from a server-side source (database, session, cookie) on every mount, overriding any locally stored value. The user can still call `setTheme` to change it - use `onThemeChange` to persist the change back.
218
+
219
+ ```tsx
220
+ // app/layout.tsx (server component)
221
+ export default async function RootLayout({ children }) {
222
+ const userTheme = await getUserTheme(); // "light" | "dark" | null
223
+
224
+ return (
225
+ <html lang="en" suppressHydrationWarning>
226
+ <body>
227
+ <ThemeProvider
228
+ initialTheme={userTheme ?? undefined}
229
+ onThemeChange={saveUserTheme}
230
+ >
231
+ {children}
232
+ </ThemeProvider>
233
+ </body>
234
+ </html>
235
+ );
236
+ }
237
+ ```
238
+
239
+ ### Nested provider in a Client Component
240
+
241
+ `ThemeProvider` renders an inline `<script>` and must be used in a Server Component. For nested providers inside Client Components, use `ClientThemeProvider` instead:
242
+
243
+ ```tsx
244
+ "use client";
245
+
246
+ import { ClientThemeProvider } from "@wrksz/themes";
247
+
248
+ export function AdminShell({ children }: { children: React.ReactNode }) {
249
+ return (
250
+ <ClientThemeProvider forcedTheme="dark">
251
+ {children}
252
+ </ClientThemeProvider>
253
+ );
254
+ }
255
+ ```
256
+
257
+ ### `useThemeValue`
258
+
259
+ Returns the value from a map that matches the current resolved theme. Returns `undefined` before the theme resolves on the client.
260
+
261
+ ```tsx
262
+ "use client";
263
+
264
+ import { useThemeValue } from "@wrksz/themes";
265
+
266
+ // strings
267
+ const label = useThemeValue({ light: "Switch to dark", dark: "Switch to light" });
268
+
269
+ // CSS values
270
+ const bg = useThemeValue({ light: "#ffffff", dark: "#0a0a0a", purple: "#6633ff" });
271
+
272
+ // any type
273
+ const icon = useThemeValue({ light: <SunIcon />, dark: <MoonIcon /> });
274
+ ```
275
+
276
+ ### Theme-aware images
277
+
278
+ Showing different images per theme has a hydration mismatch problem - `resolvedTheme` is always `undefined` on the server. Use the built-in `ThemedImage` component which shows a transparent placeholder until the theme resolves on the client:
279
+
280
+ ```tsx
281
+ import { ThemedImage } from "@wrksz/themes";
282
+
283
+ <ThemedImage
284
+ src={{ light: "/logo-light.png", dark: "/logo-dark.png" }}
285
+ alt="Logo"
286
+ width={200}
287
+ height={50}
288
+ />
289
+ ```
290
+
291
+ Works with any custom themes too:
292
+
293
+ ```tsx
294
+ <ThemedImage
295
+ src={{
296
+ light: "/logo-light.png",
297
+ dark: "/logo-dark.png",
298
+ purple: "/logo-purple.png",
299
+ }}
300
+ alt="Logo"
301
+ width={200}
302
+ height={50}
303
+ />
304
+ ```
305
+
306
+ For custom themes or `next/image`, use `resolvedTheme` directly with a fallback:
307
+
308
+ ```tsx
309
+ "use client";
310
+
311
+ import Image from "next/image";
312
+ import { useTheme } from "@wrksz/themes";
313
+
314
+ export function Logo() {
315
+ const { resolvedTheme } = useTheme();
316
+
317
+ return (
318
+ <Image
319
+ src={resolvedTheme === "dark" ? "/logo-dark.png" : "/logo-light.png"}
320
+ alt="Logo"
321
+ width={200}
322
+ height={50}
323
+ // avoids layout shift while theme is resolving
324
+ style={{ visibility: resolvedTheme ? "visible" : "hidden" }}
325
+ />
326
+ );
327
+ }
328
+ ```
329
+
171
330
  ### Class on body instead of html
172
331
 
173
332
  ```tsx
@@ -183,9 +342,12 @@ Works with CSS variables too:
183
342
  | React 19 script warning | Yes | Fixed (RSC split) |
184
343
  | `__name` minification bug | Yes | Fixed |
185
344
  | React 19 Activity/cacheComponents stale theme | Yes | Fixed (`useSyncExternalStore`) |
345
+ | Multiple classes per theme | No | Yes (`value` map with spaces) |
346
+ | Nested providers | No | Yes (per-instance store) |
186
347
  | `sessionStorage` support | No | Yes |
187
348
  | Disable storage | No | Yes (`storage: "none"`) |
188
349
  | `meta theme-color` support | No | Yes (`themeColor` prop) |
350
+ | Server-provided theme | No | Yes (`initialTheme` prop) |
189
351
  | Generic types | No | Yes (`useTheme<AppTheme>()`) |
190
352
  | Zero runtime dependencies | Yes | Yes |
191
353
 
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ReactElement } from "react";
1
2
  import { ReactNode } from "react";
2
3
  type DefaultTheme = "light" | "dark" | "system";
3
4
  type Attribute = "class" | `data-${string}`;
@@ -37,6 +38,8 @@ type ThemeProviderProps<Themes extends string = DefaultTheme> = {
37
38
  themeColor?: ThemeColor;
38
39
  /** Always follow system preference changes, even after setTheme was called */
39
40
  followSystem?: boolean;
41
+ /** Server-provided theme that overrides storage on mount (e.g. from a database). User can still call setTheme to change it. */
42
+ initialTheme?: Themes | "system";
40
43
  };
41
44
  type ThemeContextValue<Themes extends string = DefaultTheme> = {
42
45
  /** Current theme (may be "system") */
@@ -52,7 +55,30 @@ type ThemeContextValue<Themes extends string = DefaultTheme> = {
52
55
  /** Set theme */
53
56
  setTheme: (theme: Themes | "system" | ((current: Themes | "system" | undefined) => Themes | "system")) => void;
54
57
  };
58
+ declare function ClientThemeProvider<Themes extends string = DefaultTheme>({ children, themes, forcedTheme, enableSystem, defaultTheme, attribute, value: valueMap, target, disableTransitionOnChange, storage, storageKey, enableColorScheme, themeColor, followSystem, onThemeChange, initialTheme }: ThemeProviderProps<Themes>): ReactElement;
55
59
  declare function useTheme<Themes extends string = DefaultTheme>(): ThemeContextValue<Themes>;
56
- import { ReactElement } from "react";
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 };
60
+ import { ReactElement as ReactElement2 } from "react";
61
+ declare function ThemeProvider<Themes extends string = DefaultTheme>({ children, themes, forcedTheme, enableSystem, defaultTheme, attribute, value: valueMap, target, disableTransitionOnChange, storage, storageKey, enableColorScheme, nonce, onThemeChange, themeColor, followSystem, initialTheme }: ThemeProviderProps<Themes>): ReactElement2;
62
+ import { ImgHTMLAttributes, ReactElement as ReactElement3 } from "react";
63
+ type ThemedImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt"> & {
64
+ /** Map of theme name to image source */
65
+ src: Record<string, string>;
66
+ /**
67
+ * Shown before the theme resolves on the client.
68
+ * Defaults to a transparent 1x1 GIF to avoid hydration mismatch.
69
+ */
70
+ fallback?: string;
71
+ /** Alt text (required for accessibility) */
72
+ alt: string;
73
+ };
74
+ declare function ThemedImage({ src, fallback, alt,...props }: ThemedImageProps): ReactElement3;
75
+ /**
76
+ * Returns the value from the map that corresponds to the current resolved theme.
77
+ * Returns `undefined` if the theme hasn't resolved yet (e.g. during SSR).
78
+ *
79
+ * @example
80
+ * const label = useThemeValue({ light: "Switch to dark", dark: "Switch to light" });
81
+ * const color = useThemeValue({ light: "#fff", dark: "#000", purple: "#1a0a2e" });
82
+ */
83
+ declare function useThemeValue<T>(map: Record<string, T>): T | undefined;
84
+ export { useThemeValue, useTheme, ValueObject, ThemedImageProps, ThemedImage, ThemeProviderProps, ThemeProvider, ThemeContextValue, ThemeColor, StorageType, DefaultTheme, ClientThemeProvider, Attribute };
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  "use client";
2
+ // src/client-provider.tsx
3
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
4
+
2
5
  // src/context.ts
3
6
  import { createContext, useContext } from "react";
4
7
  var ThemeContext = createContext(undefined);
@@ -9,8 +12,6 @@ function useTheme() {
9
12
  }
10
13
  return ctx;
11
14
  }
12
- // src/client-provider.tsx
13
- import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
14
15
 
15
16
  // src/store.ts
16
17
  var SERVER_SNAPSHOT = { theme: undefined, systemTheme: undefined };
@@ -84,7 +85,8 @@ function ClientThemeProvider({
84
85
  enableColorScheme = true,
85
86
  themeColor,
86
87
  followSystem = false,
87
- onThemeChange
88
+ onThemeChange,
89
+ initialTheme
88
90
  }) {
89
91
  const resolvedDefault = defaultTheme ?? (enableSystem ? "system" : "light");
90
92
  const storeRef = useRef(createThemeStore());
@@ -117,9 +119,9 @@ function ClientThemeProvider({
117
119
  }
118
120
  for (const attr of attrs) {
119
121
  if (attr === "class") {
120
- const toRemove = themes.map((t) => valueMap?.[t] ?? t);
122
+ const toRemove = themes.flatMap((t) => (valueMap?.[t] ?? t).split(" "));
121
123
  el.classList.remove(...toRemove);
122
- el.classList.add(attrValue);
124
+ el.classList.add(...attrValue.split(" "));
123
125
  } else {
124
126
  el.setAttribute(attr, attrValue);
125
127
  }
@@ -147,6 +149,15 @@ function ClientThemeProvider({
147
149
  if (forcedTheme) {
148
150
  setStoreTheme(forcedTheme);
149
151
  applyToDom(forcedTheme);
152
+ } else if (initialTheme) {
153
+ setStoreTheme(initialTheme);
154
+ applyToDom(initialTheme === "system" ? sys ?? "light" : initialTheme);
155
+ try {
156
+ if (storage !== "none") {
157
+ const s = storage === "localStorage" ? localStorage : sessionStorage;
158
+ s.setItem(storageKey, initialTheme);
159
+ }
160
+ } catch {}
150
161
  } else {
151
162
  let stored = null;
152
163
  try {
@@ -155,7 +166,7 @@ function ClientThemeProvider({
155
166
  stored = s.getItem(storageKey);
156
167
  }
157
168
  } catch {}
158
- const initial = stored && themes.includes(stored) ? stored : resolvedDefault;
169
+ const initial = !followSystem && stored && themes.includes(stored) ? stored : resolvedDefault;
159
170
  setStoreTheme(initial);
160
171
  applyToDom(initial === "system" ? sys ?? "light" : initial);
161
172
  }
@@ -177,6 +188,7 @@ function ClientThemeProvider({
177
188
  return () => mq.removeEventListener("change", handler);
178
189
  }, [
179
190
  forcedTheme,
191
+ initialTheme,
180
192
  resolvedDefault,
181
193
  storage,
182
194
  storageKey,
@@ -203,10 +215,10 @@ function ClientThemeProvider({
203
215
  };
204
216
  }, [applyToDom, forcedTheme, getSnapshot]);
205
217
  useEffect(() => {
206
- if (storage === "none")
218
+ if (storage === "none" || storage === "sessionStorage")
207
219
  return;
208
220
  const handler = (e) => {
209
- if (e.key !== storageKey || !e.newValue)
221
+ if (e.storageArea !== localStorage || e.key !== storageKey || !e.newValue)
210
222
  return;
211
223
  if (themes.includes(e.newValue)) {
212
224
  const newTheme = e.newValue;
@@ -247,12 +259,13 @@ function ClientThemeProvider({
247
259
  children
248
260
  }, undefined, false, undefined, this);
249
261
  }
250
-
251
262
  // src/script.ts
252
- function themeScript(storageKey, attribute, defaultTheme, enableSystem, enableColorScheme, forcedTheme, themes, value, target, storage, themeColors) {
263
+ function themeScript(storageKey, attribute, defaultTheme, enableSystem, enableColorScheme, forcedTheme, themes, value, target, storage, themeColors, initialTheme) {
253
264
  let theme;
254
265
  if (forcedTheme) {
255
266
  theme = forcedTheme;
267
+ } else if (initialTheme && themes.includes(initialTheme)) {
268
+ theme = initialTheme;
256
269
  } else {
257
270
  let stored = null;
258
271
  try {
@@ -273,9 +286,9 @@ function themeScript(storageKey, attribute, defaultTheme, enableSystem, enableCo
273
286
  const attrs = Array.isArray(attribute) ? attribute : [attribute];
274
287
  for (const attr of attrs) {
275
288
  if (attr === "class") {
276
- const toRemove = themes.map((t) => value?.[t] || t);
289
+ const toRemove = themes.flatMap((t) => (value?.[t] || t).split(" "));
277
290
  el.classList.remove(...toRemove);
278
- el.classList.add(attrValue);
291
+ el.classList.add(...attrValue.split(" "));
279
292
  } else {
280
293
  el.setAttribute(attr, attrValue);
281
294
  }
@@ -309,7 +322,8 @@ function getScript(config) {
309
322
  JSON.stringify(config.value ?? null),
310
323
  JSON.stringify(config.target),
311
324
  JSON.stringify(config.storage),
312
- JSON.stringify(config.themeColors ?? null)
325
+ JSON.stringify(config.themeColors ?? null),
326
+ JSON.stringify(config.initialTheme ?? null)
313
327
  ].join(",");
314
328
  return `(${fn})(${args})`;
315
329
  }
@@ -333,7 +347,8 @@ function ThemeProvider({
333
347
  nonce,
334
348
  onThemeChange,
335
349
  themeColor,
336
- followSystem = false
350
+ followSystem = false,
351
+ initialTheme
337
352
  }) {
338
353
  const resolvedDefault = defaultTheme ?? (enableSystem ? "system" : "light");
339
354
  return /* @__PURE__ */ jsxDEV2(Fragment, {
@@ -351,7 +366,8 @@ function ThemeProvider({
351
366
  value: valueMap,
352
367
  target,
353
368
  storage,
354
- themeColors: themeColor
369
+ themeColors: themeColor,
370
+ initialTheme
355
371
  })
356
372
  },
357
373
  nonce
@@ -371,12 +387,42 @@ function ThemeProvider({
371
387
  themeColor,
372
388
  followSystem,
373
389
  onThemeChange,
390
+ initialTheme,
374
391
  children
375
392
  }, undefined, false, undefined, this)
376
393
  ]
377
394
  }, undefined, true, undefined, this);
378
395
  }
396
+ // src/themed-image.tsx
397
+ import { jsxDEV as jsxDEV3 } from "react/jsx-dev-runtime";
398
+
399
+ var TRANSPARENT_FALLBACK = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
400
+ function ThemedImage({
401
+ src,
402
+ fallback = TRANSPARENT_FALLBACK,
403
+ alt,
404
+ ...props
405
+ }) {
406
+ const { resolvedTheme } = useTheme();
407
+ const resolvedSrc = resolvedTheme && src[resolvedTheme] || fallback;
408
+ return /* @__PURE__ */ jsxDEV3("img", {
409
+ src: resolvedSrc,
410
+ alt,
411
+ ...props
412
+ }, undefined, false, undefined, this);
413
+ }
414
+ // src/use-theme-value.ts
415
+
416
+ function useThemeValue(map) {
417
+ const { resolvedTheme } = useTheme();
418
+ if (!resolvedTheme)
419
+ return;
420
+ return map[resolvedTheme];
421
+ }
379
422
  export {
423
+ useThemeValue,
380
424
  useTheme,
381
- ThemeProvider
425
+ ThemedImage,
426
+ ThemeProvider,
427
+ ClientThemeProvider
382
428
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrksz/themes",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A modern, fully-featured theme management library for Next.js",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "devDependencies": {
24
24
  "@biomejs/biome": "^2.4.8",
25
+ "@testing-library/react": "^16.3.2",
25
26
  "@types/bun": "^1.3.11",
26
27
  "@types/react": "^19.2.14",
27
28
  "@types/react-dom": "^19.2.3",