nualt-theme-toggle 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Sarazin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # nualt-theme-toggle
2
+
3
+ A dev-only theme toggle for Next.js projects.
4
+
5
+ It lets you force light or dark mode while building a page, without opening Chrome DevTools or changing the OS theme.
6
+
7
+ ## Quick Start
8
+
9
+ From a Next.js App Router project:
10
+
11
+ ```bash
12
+ pnpm dlx nualt-theme-toggle init
13
+ ```
14
+
15
+ or:
16
+
17
+ ```bash
18
+ npx nualt-theme-toggle init
19
+ ```
20
+
21
+ The initializer:
22
+
23
+ - installs `nualt-theme-toggle` if it is missing
24
+ - imports `nualt-theme-toggle/styles.css`
25
+ - renders `<ThemeToggle />` in `app/layout` or `src/app/layout`
26
+ - adds the Tailwind v4 class-based dark variant
27
+ - adds `:root.light` / `:root.dark` overrides when the default Next theme variables are present
28
+
29
+ Then run your dev server, press `t` to switch theme, and press `Shift+H` to hide or show the floating button.
30
+
31
+ ## Local Tarball Test
32
+
33
+ Before publishing to npm:
34
+
35
+ ```bash
36
+ cd /Users/thomassarazin/Lab/nualt-theme-toggle
37
+ pnpm install
38
+ pnpm build
39
+ pnpm pack
40
+ ```
41
+
42
+ In a fresh Next.js project:
43
+
44
+ ```bash
45
+ pnpm add /Users/thomassarazin/Lab/nualt-theme-toggle/nualt-theme-toggle-0.1.0.tgz
46
+ pnpm exec nualt-theme-toggle init --no-install
47
+ pnpm dev
48
+ ```
49
+
50
+ ## Manual Usage
51
+
52
+ ```tsx
53
+ import { ThemeToggle } from "nualt-theme-toggle";
54
+ import "nualt-theme-toggle/styles.css";
55
+
56
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
57
+ return (
58
+ <html lang="en">
59
+ <body>
60
+ {children}
61
+ <ThemeToggle />
62
+ </body>
63
+ </html>
64
+ );
65
+ }
66
+ ```
67
+
68
+ For Tailwind v4, your global CSS must include:
69
+
70
+ ```css
71
+ @custom-variant dark (&:where(.dark, .dark *));
72
+ ```
73
+
74
+ If your app uses the default Next variables from `create-next-app`, keep system mode as the default and add forced overrides:
75
+
76
+ ```css
77
+ :root.light {
78
+ --background: #ffffff;
79
+ --foreground: #171717;
80
+ }
81
+
82
+ :root.dark {
83
+ --background: #0a0a0a;
84
+ --foreground: #ededed;
85
+ }
86
+ ```
87
+
88
+ The `init` command does this for the default template.
89
+
90
+ ## API
91
+
92
+ ```tsx
93
+ type ThemeToggleProps = {
94
+ buttonClassName?: string;
95
+ className?: string;
96
+ enabledInProduction?: boolean;
97
+ labels?: {
98
+ light?: string;
99
+ dark?: string;
100
+ };
101
+ shortcut?: string | false;
102
+ showFloatingButton?: boolean;
103
+ visibilityShortcut?: string | false;
104
+ };
105
+ ```
106
+
107
+ Defaults:
108
+
109
+ - dev-only
110
+ - shortcut: `t`
111
+ - visibility shortcut: `Shift+H`
112
+ - root classes: `html.light` / `html.dark`
113
+ - no localStorage persistence
114
+ - system preference stays active until you force a mode
115
+
116
+ ## License
117
+
118
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ const PACKAGE_NAME = "nualt-theme-toggle";
6
+ const command = process.argv[2] ?? "help";
7
+ function printHelp() {
8
+ console.log(`nualt-theme-toggle
9
+
10
+ Usage:
11
+ nualt-theme-toggle init [options]
12
+ nualt-theme-toggle help
13
+
14
+ Options:
15
+ --cwd <path> Project directory. Defaults to the current directory.
16
+ --no-install Skip dependency installation.
17
+ --package <spec> Package specifier to install. Defaults to nualt-theme-toggle.
18
+ --dry-run Print what would change without writing files.
19
+ `);
20
+ }
21
+ function parseInitOptions(args) {
22
+ const options = {
23
+ cwd: process.cwd(),
24
+ dryRun: false,
25
+ install: true,
26
+ packageSpecifier: PACKAGE_NAME,
27
+ };
28
+ for (let index = 0; index < args.length; index += 1) {
29
+ const arg = args[index];
30
+ if (arg === "--cwd") {
31
+ const value = args[index + 1];
32
+ if (!value)
33
+ throw new Error("Missing value for --cwd.");
34
+ options.cwd = resolve(value);
35
+ index += 1;
36
+ continue;
37
+ }
38
+ if (arg === "--package") {
39
+ const value = args[index + 1];
40
+ if (!value)
41
+ throw new Error("Missing value for --package.");
42
+ options.packageSpecifier = value;
43
+ index += 1;
44
+ continue;
45
+ }
46
+ if (arg === "--no-install") {
47
+ options.install = false;
48
+ continue;
49
+ }
50
+ if (arg === "--dry-run") {
51
+ options.dryRun = true;
52
+ continue;
53
+ }
54
+ throw new Error(`Unknown option: ${arg}`);
55
+ }
56
+ return options;
57
+ }
58
+ function readPackageJson(cwd) {
59
+ const filePath = join(cwd, "package.json");
60
+ if (!existsSync(filePath)) {
61
+ throw new Error(`No package.json found in ${cwd}.`);
62
+ }
63
+ return JSON.parse(readFileSync(filePath, "utf8"));
64
+ }
65
+ function hasDependency(packageJson) {
66
+ return Boolean(packageJson.dependencies?.[PACKAGE_NAME] ||
67
+ packageJson.devDependencies?.[PACKAGE_NAME] ||
68
+ packageJson.peerDependencies?.[PACKAGE_NAME]);
69
+ }
70
+ function detectPackageManager(cwd) {
71
+ if (existsSync(join(cwd, "pnpm-lock.yaml")))
72
+ return "pnpm";
73
+ if (existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock"))) {
74
+ return "bun";
75
+ }
76
+ if (existsSync(join(cwd, "yarn.lock")))
77
+ return "yarn";
78
+ return "npm";
79
+ }
80
+ function installPackage(cwd, packageSpecifier) {
81
+ const packageManager = detectPackageManager(cwd);
82
+ const argsByManager = {
83
+ bun: ["add", packageSpecifier],
84
+ npm: ["install", packageSpecifier],
85
+ pnpm: ["add", packageSpecifier],
86
+ yarn: ["add", packageSpecifier],
87
+ };
88
+ console.log(`Installing ${packageSpecifier} with ${packageManager}...`);
89
+ const result = spawnSync(packageManager, argsByManager[packageManager], {
90
+ cwd,
91
+ shell: process.platform === "win32",
92
+ stdio: "inherit",
93
+ });
94
+ if (result.status !== 0) {
95
+ throw new Error(`Failed to install ${packageSpecifier}.`);
96
+ }
97
+ }
98
+ function findLayoutFile(cwd) {
99
+ const candidates = [
100
+ "app/layout.tsx",
101
+ "app/layout.jsx",
102
+ "app/layout.ts",
103
+ "app/layout.js",
104
+ "src/app/layout.tsx",
105
+ "src/app/layout.jsx",
106
+ "src/app/layout.ts",
107
+ "src/app/layout.js",
108
+ ];
109
+ return candidates.map((candidate) => join(cwd, candidate)).find(existsSync);
110
+ }
111
+ function findGlobalCssFile(cwd, layoutFile) {
112
+ if (layoutFile) {
113
+ const layoutSource = readFileSync(layoutFile, "utf8");
114
+ const cssImport = layoutSource.match(/import\s+["'](\.\/?[^"']*\.css)["'];?/);
115
+ if (cssImport?.[1]) {
116
+ const resolved = resolve(dirname(layoutFile), cssImport[1]);
117
+ if (existsSync(resolved))
118
+ return resolved;
119
+ }
120
+ }
121
+ const candidates = [
122
+ "app/globals.css",
123
+ "src/app/globals.css",
124
+ "styles/globals.css",
125
+ ];
126
+ return candidates.map((candidate) => join(cwd, candidate)).find(existsSync);
127
+ }
128
+ function addImport(source, importLine) {
129
+ if (source.includes(importLine))
130
+ return source;
131
+ const directive = source.match(/^["']use [^"']+["'];?\n+/);
132
+ const insertionPoint = directive ? directive[0].length : 0;
133
+ return `${source.slice(0, insertionPoint)}${importLine}\n${source.slice(insertionPoint)}`;
134
+ }
135
+ function patchLayoutSource(source) {
136
+ let nextSource = source;
137
+ nextSource = addImport(nextSource, `import { ThemeToggle } from "${PACKAGE_NAME}";`);
138
+ nextSource = addImport(nextSource, `import "${PACKAGE_NAME}/styles.css";`);
139
+ if (nextSource.includes("<ThemeToggle"))
140
+ return nextSource;
141
+ const oneLineBody = /<body([^>]*)>\s*\{children\}\s*<\/body>/;
142
+ if (oneLineBody.test(nextSource)) {
143
+ return nextSource.replace(oneLineBody, `<body$1>\n {children}\n <ThemeToggle />\n </body>`);
144
+ }
145
+ const closingBodyIndex = nextSource.lastIndexOf("</body>");
146
+ if (closingBodyIndex === -1) {
147
+ throw new Error("Could not find a <body> tag in the Next.js root layout.");
148
+ }
149
+ return `${nextSource.slice(0, closingBodyIndex)} <ThemeToggle />\n ${nextSource.slice(closingBodyIndex)}`;
150
+ }
151
+ function addTailwindClassDarkVariant(source) {
152
+ if (source.includes("@custom-variant dark"))
153
+ return source;
154
+ if (source.includes('@import "tailwindcss";')) {
155
+ return source.replace('@import "tailwindcss";', '@import "tailwindcss";\n@custom-variant dark (&:where(.dark, .dark *));');
156
+ }
157
+ if (source.includes("@import 'tailwindcss';")) {
158
+ return source.replace("@import 'tailwindcss';", "@import 'tailwindcss';\n@custom-variant dark (&:where(.dark, .dark *));");
159
+ }
160
+ return source;
161
+ }
162
+ function extractRootVariables(source) {
163
+ const lightMatch = source.match(/:root\s*\{([\s\S]*?)\n\}/m);
164
+ const darkMatch = source.match(/@media\s*\(prefers-color-scheme:\s*dark\)\s*\{\s*:root\s*\{([\s\S]*?)\n\s*\}\s*\n\s*\}/m);
165
+ if (!lightMatch?.[1] || !darkMatch?.[1])
166
+ return null;
167
+ return { dark: darkMatch[1], light: lightMatch[1] };
168
+ }
169
+ function addForcedRootVariables(source) {
170
+ if (source.includes(":root.light") && source.includes(":root.dark")) {
171
+ return source;
172
+ }
173
+ const variables = extractRootVariables(source);
174
+ if (!variables)
175
+ return source;
176
+ return `${source.trimEnd()}\n\n:root.light {${variables.light}\n}\n\n:root.dark {${variables.dark}\n}\n`;
177
+ }
178
+ function addColorSchemeFallback(source) {
179
+ if (source.includes("html.light") || source.includes("color-scheme: light")) {
180
+ return source;
181
+ }
182
+ return `${source.trimEnd()}\n\nhtml.light {\n color-scheme: light;\n}\n\nhtml.dark {\n color-scheme: dark;\n}\n`;
183
+ }
184
+ function patchCssSource(source) {
185
+ const withTailwindVariant = addTailwindClassDarkVariant(source);
186
+ const withForcedRootVariables = addForcedRootVariables(withTailwindVariant);
187
+ return addColorSchemeFallback(withForcedRootVariables);
188
+ }
189
+ function writeIfChanged(filePath, nextSource, dryRun) {
190
+ const currentSource = readFileSync(filePath, "utf8");
191
+ if (currentSource === nextSource) {
192
+ console.log(`Already configured: ${filePath}`);
193
+ return;
194
+ }
195
+ if (!dryRun)
196
+ writeFileSync(filePath, nextSource);
197
+ console.log(`${dryRun ? "Would update" : "Updated"}: ${filePath}`);
198
+ }
199
+ function init() {
200
+ const options = parseInitOptions(process.argv.slice(3));
201
+ const packageJson = readPackageJson(options.cwd);
202
+ if (!packageJson.dependencies?.next && !packageJson.devDependencies?.next) {
203
+ throw new Error("This initializer is for Next.js projects.");
204
+ }
205
+ if (options.install && !hasDependency(packageJson)) {
206
+ if (options.dryRun) {
207
+ console.log(`Would install ${options.packageSpecifier}.`);
208
+ }
209
+ else {
210
+ installPackage(options.cwd, options.packageSpecifier);
211
+ }
212
+ }
213
+ const layoutFile = findLayoutFile(options.cwd);
214
+ if (!layoutFile) {
215
+ throw new Error("Could not find app/layout.tsx or src/app/layout.tsx.");
216
+ }
217
+ const cssFile = findGlobalCssFile(options.cwd, layoutFile);
218
+ writeIfChanged(layoutFile, patchLayoutSource(readFileSync(layoutFile, "utf8")), options.dryRun);
219
+ if (cssFile) {
220
+ writeIfChanged(cssFile, patchCssSource(readFileSync(cssFile, "utf8")), options.dryRun);
221
+ }
222
+ else {
223
+ console.warn("No global CSS file found. Add the Tailwind dark variant manually.");
224
+ }
225
+ console.log("nualt-theme-toggle is ready. Run dev and press t.");
226
+ }
227
+ try {
228
+ if (command === "init") {
229
+ init();
230
+ }
231
+ else {
232
+ printHelp();
233
+ }
234
+ }
235
+ catch (error) {
236
+ const message = error instanceof Error ? error.message : String(error);
237
+ console.error(`nualt-theme-toggle: ${message}`);
238
+ process.exit(1);
239
+ }
@@ -0,0 +1,2 @@
1
+ export { NualtThemeDevTools, ThemeToggle, type ThemeToggleLabels, type ThemeToggleProps, type ThemeToggleTheme, } from "./theme-toggle.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,kBAAkB,EAClB,WAAW,EACX,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,GACtB,MAAM,mBAAmB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ "use client";
2
+ export { NualtThemeDevTools, ThemeToggle, } from "./theme-toggle.js";
@@ -0,0 +1,102 @@
1
+ .nualt-theme-toggle {
2
+ --nualt-theme-toggle-bg: color-mix(
3
+ in srgb,
4
+ var(--background, #fff) 86%,
5
+ transparent
6
+ );
7
+ --nualt-theme-toggle-color: var(--foreground, #111);
8
+ --nualt-theme-toggle-border: color-mix(
9
+ in srgb,
10
+ var(--foreground, #111) 16%,
11
+ transparent
12
+ );
13
+ --nualt-theme-toggle-shadow:
14
+ 0 18px 45px rgb(0 0 0 / 0.16),
15
+ inset 0 1px 0 rgb(255 255 255 / 0.12);
16
+ bottom: 1.5rem;
17
+ position: fixed;
18
+ right: 1.5rem;
19
+ z-index: 2147483647;
20
+ }
21
+
22
+ .nualt-theme-toggle__button {
23
+ align-items: center;
24
+ backdrop-filter: blur(14px);
25
+ -webkit-backdrop-filter: blur(14px);
26
+ background: var(--nualt-theme-toggle-bg);
27
+ border: 1px solid var(--nualt-theme-toggle-border);
28
+ border-radius: 999px;
29
+ box-shadow: var(--nualt-theme-toggle-shadow);
30
+ color: var(--nualt-theme-toggle-color);
31
+ display: inline-flex;
32
+ font: 650 0.8125rem/1.1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
33
+ sans-serif;
34
+ gap: 0.625rem;
35
+ justify-content: space-between;
36
+ min-height: 2.625rem;
37
+ padding: 0.4375rem 0.5rem 0.4375rem 0.875rem;
38
+ transition:
39
+ border-color 160ms ease,
40
+ box-shadow 160ms ease,
41
+ transform 160ms ease;
42
+ width: 9.75rem;
43
+ }
44
+
45
+ .nualt-theme-toggle__button:hover {
46
+ --nualt-theme-toggle-border: color-mix(
47
+ in srgb,
48
+ var(--foreground, #111) 28%,
49
+ transparent
50
+ );
51
+ transform: translateY(-1px);
52
+ }
53
+
54
+ .nualt-theme-toggle__button:active {
55
+ transform: translateY(0);
56
+ }
57
+
58
+ .nualt-theme-toggle__button:focus-visible {
59
+ outline: 2px solid currentColor;
60
+ outline-offset: 3px;
61
+ }
62
+
63
+ .nualt-theme-toggle__label {
64
+ display: block;
65
+ overflow: hidden;
66
+ text-align: left;
67
+ text-overflow: ellipsis;
68
+ white-space: nowrap;
69
+ }
70
+
71
+ .nualt-theme-toggle__shortcut {
72
+ align-items: center;
73
+ background:
74
+ linear-gradient(
75
+ 180deg,
76
+ color-mix(in srgb, var(--background, #fff) 92%, currentColor),
77
+ color-mix(in srgb, var(--background, #fff) 82%, currentColor)
78
+ );
79
+ border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
80
+ border-radius: 0.5rem;
81
+ box-shadow:
82
+ inset 0 1px 0 rgb(255 255 255 / 0.24),
83
+ inset 0 -1px 0 rgb(0 0 0 / 0.08),
84
+ 0 1px 1px rgb(0 0 0 / 0.08);
85
+ color: var(--nualt-theme-toggle-color);
86
+ display: inline-flex;
87
+ font: inherit;
88
+ font-size: 0.75rem;
89
+ font-weight: 750;
90
+ height: 1.75rem;
91
+ justify-content: center;
92
+ letter-spacing: 0;
93
+ min-width: 1.75rem;
94
+ padding: 0 0.5rem;
95
+ text-transform: uppercase;
96
+ }
97
+
98
+ @media (prefers-reduced-motion: reduce) {
99
+ .nualt-theme-toggle__button {
100
+ transition: none;
101
+ }
102
+ }
@@ -0,0 +1,19 @@
1
+ import { type ButtonHTMLAttributes } from "react";
2
+ export type ThemeToggleTheme = "dark" | "light";
3
+ export type ThemeToggleLabels = {
4
+ dark?: string;
5
+ light?: string;
6
+ };
7
+ export type ThemeToggleProps = {
8
+ buttonClassName?: string;
9
+ buttonProps?: Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-label" | "aria-pressed" | "children" | "className" | "onClick" | "type">;
10
+ className?: string;
11
+ enabledInProduction?: boolean;
12
+ labels?: ThemeToggleLabels;
13
+ shortcut?: string | false;
14
+ showFloatingButton?: boolean;
15
+ visibilityShortcut?: string | false;
16
+ };
17
+ export declare function ThemeToggle({ buttonClassName, buttonProps, className, enabledInProduction, labels, shortcut, showFloatingButton, visibilityShortcut, }: ThemeToggleProps): import("react/jsx-runtime").JSX.Element | null;
18
+ export declare const NualtThemeDevTools: typeof ThemeToggle;
19
+ //# sourceMappingURL=theme-toggle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme-toggle.d.ts","sourceRoot":"","sources":["../src/theme-toggle.tsx"],"names":[],"mappings":"AAEA,OAAO,EAKL,KAAK,oBAAoB,EAC1B,MAAM,OAAO,CAAC;AAEf,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,OAAO,CAAC;AAEhD,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,IAAI,CAChB,oBAAoB,CAAC,iBAAiB,CAAC,EACvC,YAAY,GAAG,cAAc,GAAG,UAAU,GAAG,WAAW,GAAG,SAAS,GAAG,MAAM,CAC9E,CAAC;IACF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IAC1B,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,kBAAkB,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;CACrC,CAAC;AAoCF,wBAAgB,WAAW,CAAC,EAC1B,eAAoB,EACpB,WAAW,EACX,SAAc,EACd,mBAA2B,EAC3B,MAAM,EACN,QAAc,EACd,kBAAyB,EACzB,kBAAwB,GACzB,EAAE,gBAAgB,kDA6FlB;AAED,eAAO,MAAM,kBAAkB,oBAAc,CAAC"}
@@ -0,0 +1,102 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useState, useSyncExternalStore, } from "react";
4
+ const defaultLabels = {
5
+ dark: "Dark mode",
6
+ light: "Light mode",
7
+ };
8
+ function getSystemTheme() {
9
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
10
+ ? "dark"
11
+ : "light";
12
+ }
13
+ function subscribeToSystemTheme(onStoreChange) {
14
+ const colorScheme = window.matchMedia("(prefers-color-scheme: dark)");
15
+ colorScheme.addEventListener("change", onStoreChange);
16
+ return () => {
17
+ colorScheme.removeEventListener("change", onStoreChange);
18
+ };
19
+ }
20
+ function isEditableTarget(target) {
21
+ if (target instanceof HTMLInputElement)
22
+ return true;
23
+ if (target instanceof HTMLTextAreaElement)
24
+ return true;
25
+ if (target instanceof HTMLSelectElement)
26
+ return true;
27
+ if (target instanceof HTMLElement && target.isContentEditable)
28
+ return true;
29
+ return false;
30
+ }
31
+ function normalizeShortcut(shortcut) {
32
+ if (!shortcut)
33
+ return false;
34
+ return shortcut.toLowerCase();
35
+ }
36
+ export function ThemeToggle({ buttonClassName = "", buttonProps, className = "", enabledInProduction = false, labels, shortcut = "t", showFloatingButton = true, visibilityShortcut = "h", }) {
37
+ const enabled = enabledInProduction || process.env.NODE_ENV !== "production";
38
+ const systemTheme = useSyncExternalStore(subscribeToSystemTheme, getSystemTheme, () => "light");
39
+ const [themeOverride, setThemeOverride] = useState(null);
40
+ const theme = themeOverride ?? systemTheme;
41
+ const isDark = theme === "dark";
42
+ const shortcutKey = normalizeShortcut(shortcut);
43
+ const visibilityShortcutKey = normalizeShortcut(visibilityShortcut);
44
+ const resolvedLabels = { ...defaultLabels, ...labels };
45
+ const [buttonVisible, setButtonVisible] = useState(true);
46
+ useEffect(() => {
47
+ if (!enabled)
48
+ return;
49
+ document.documentElement.classList.toggle("dark", themeOverride === "dark");
50
+ document.documentElement.classList.toggle("light", themeOverride === "light");
51
+ }, [enabled, themeOverride]);
52
+ const toggleTheme = useCallback(() => {
53
+ setThemeOverride((currentTheme) => {
54
+ const activeTheme = currentTheme ?? systemTheme;
55
+ return activeTheme === "dark" ? "light" : "dark";
56
+ });
57
+ }, [systemTheme]);
58
+ useEffect(() => {
59
+ if (!enabled || !shortcutKey)
60
+ return;
61
+ function onKeyDown(event) {
62
+ if (!event.key)
63
+ return;
64
+ if (event.key.toLowerCase() !== shortcutKey)
65
+ return;
66
+ if (event.metaKey || event.ctrlKey || event.altKey)
67
+ return;
68
+ if (isEditableTarget(event.target))
69
+ return;
70
+ event.preventDefault();
71
+ toggleTheme();
72
+ }
73
+ window.addEventListener("keydown", onKeyDown);
74
+ return () => window.removeEventListener("keydown", onKeyDown);
75
+ }, [enabled, shortcutKey, toggleTheme]);
76
+ useEffect(() => {
77
+ if (!enabled || !visibilityShortcutKey)
78
+ return;
79
+ function onKeyDown(event) {
80
+ if (!event.key)
81
+ return;
82
+ if (event.key.toLowerCase() !== visibilityShortcutKey)
83
+ return;
84
+ if (!event.shiftKey)
85
+ return;
86
+ if (event.metaKey || event.ctrlKey || event.altKey)
87
+ return;
88
+ if (isEditableTarget(event.target))
89
+ return;
90
+ event.preventDefault();
91
+ setButtonVisible((visible) => !visible);
92
+ }
93
+ window.addEventListener("keydown", onKeyDown);
94
+ return () => window.removeEventListener("keydown", onKeyDown);
95
+ }, [enabled, visibilityShortcutKey]);
96
+ if (!enabled || !showFloatingButton || !buttonVisible)
97
+ return null;
98
+ return (_jsx("div", { className: ["nualt-theme-toggle", className].filter(Boolean).join(" "), children: _jsxs("button", { ...buttonProps, type: "button", onClick: toggleTheme, className: ["nualt-theme-toggle__button", buttonClassName]
99
+ .filter(Boolean)
100
+ .join(" "), "aria-label": "Toggle light and dark mode with the T key. Press Shift+H to hide or show this button.", "aria-pressed": isDark, children: [_jsx("span", { className: "nualt-theme-toggle__label", children: isDark ? resolvedLabels.dark : resolvedLabels.light }), shortcutKey ? (_jsx("kbd", { className: "nualt-theme-toggle__shortcut", children: shortcutKey.toUpperCase() })) : null] }) }));
101
+ }
102
+ export const NualtThemeDevTools = ThemeToggle;
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "nualt-theme-toggle",
3
+ "version": "0.1.0",
4
+ "description": "A dev-only theme toggle for Next.js projects.",
5
+ "license": "MIT",
6
+ "author": "Thomas Sarazin",
7
+ "type": "module",
8
+ "packageManager": "pnpm@11.1.2",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/thomassarazin/nualt-theme-toggle.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/thomassarazin/nualt-theme-toggle/issues"
15
+ },
16
+ "homepage": "https://github.com/thomassarazin/nualt-theme-toggle#readme",
17
+ "sideEffects": [
18
+ "./dist/styles.css"
19
+ ],
20
+ "files": [
21
+ "dist",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ },
30
+ "./styles.css": "./dist/styles.css",
31
+ "./package.json": "./package.json"
32
+ },
33
+ "bin": {
34
+ "nualt-theme-toggle": "dist/cli.js"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.build.json && node scripts/copy-assets.mjs",
38
+ "typecheck": "tsc -p tsconfig.build.json --noEmit",
39
+ "pack:local": "pnpm pack"
40
+ },
41
+ "peerDependencies": {
42
+ "react": ">=18.2.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^24.10.1",
46
+ "@types/react": "^19.2.7",
47
+ "typescript": "^5.9.3"
48
+ },
49
+ "keywords": [
50
+ "nextjs",
51
+ "react",
52
+ "theme",
53
+ "dark-mode",
54
+ "tailwindcss",
55
+ "devtools"
56
+ ]
57
+ }