@jslibkit/tailwind-theme-engine
v2.1.0
Published
Dynamic runtime theming for Tailwind CSS using semantic CSS variable color scales.
Maintainers
Readme
tailwind-theme-engine
Switch your whole app's colors at runtime — with plain Tailwind classes.
<div class="bg-primary-500 text-primary-100">Hello</div>You write bg-primary-500 once. The engine decides what "primary" means right
now: purple theme, green theme, light mode, dark mode. Switching is instant.
Tailwind never rebuilds.
What you get
- Multiple themes from one small config file (purple, green, your brand — anything).
- A full 50–950 shade scale generated automatically from ONE base color per name.
- Runtime switching — change theme or dark mode with one function call.
- Dark mode for free — the shade scale flips (50 ↔ 950), your classes stay the same.
- System mode — follow the visitor's OS light/dark setting, live.
- No flash on refresh — a tiny generated script restores the visitor's choice before the page paints.
- Custom color names — call them
primary,accent,surface, whatever. They all become real Tailwind classes. - TypeScript types included.
Requirements
- Node.js 18 or newer
- Tailwind CSS 3 or newer (v4 recommended)
- React 18 or 19 (only if you use the React hook — the core works without React)
- Your app uses ESM / a bundler (Vite, Next.js, etc. — all fine)
Install in a NEW project (React + Tailwind v4)
Follow these steps in order. Don't skip any.
Step 1 — Install the package
Open a terminal in your project folder and run ONE of these:
npm install @jslibkit/tailwind-theme-engine
# or
bun add @jslibkit/tailwind-theme-engine
# or
pnpm add @jslibkit/tailwind-theme-engineStep 2 — Create the config file
npx tte init- This creates a file called
tailwind-theme-engine.config.jsin your project root. - If the file already exists, nothing is touched (use
npx tte init --forceto reset it).
Step 3 — Open the config and set your colors
Open tailwind-theme-engine.config.js. It looks like this:
export const storageKey = "app-theme"; // where the chosen theme is saved
export const modeStorageKey = "app-color-mode"; // where light/dark is saved
export const themeDefinitions = {
purple: {
label: "Purple",
colors: {
primary: "#8b5cf6",
secondary: "#ec4899",
tertiary: "#111827",
success: "#22c55e",
warning: "#f59e0b",
info: "#0ea5e9",
danger: "#ef4444"
}
},
green: {
label: "Green",
colors: { /* ... */ }
}
};Rules:
- Each key under
themeDefinitions(likepurple) is a theme name. - Each key under
colors(likeprimary) becomes Tailwind classes (bg-primary-500,text-primary-50, ...). - You give ONE color per name — the engine generates all 11 shades from it.
- The
500shade is EXACTLY the color you typed. - Any CSS color works:
#8b5cf6,rgb(139 92 246),hsl(258 90% 66%),oklch(0.6 0.2 290), evenrebeccapurple. - Add or rename colors freely (
accent,surface, ...). Just use the same names in every theme.
Step 4 — Generate the theme files
npx tte buildThis creates a theme/ folder:
theme/
purple.css ← CSS variables for the purple theme
purple.js ← same data as a JS module
green.css
green.js
index.js ← exports: themes, themeOptions, storageKey, modeStorageKey
bridge.css ← registers your Tailwind classes (v4)
tailwind-preset.js ← same thing for Tailwind v3
no-flash.js ← anti-flash script for your <head>- Run
npx tte buildagain ANY time you change the config. It overwrites its own files automatically. - Want it somewhere else?
npx tte build --out-dir src/theme
Step 5 — Wire up your CSS
Open your main CSS file (the one with @import "tailwindcss") and make it:
@import "tailwindcss";
@import "./theme/bridge.css";
@import "./theme/purple.css";- Line 2 registers the Tailwind classes for YOUR color names.
- Line 3 picks which theme paints FIRST (before any JavaScript runs). Pick your default.
- Adjust the
./theme/path to wherever the folder actually is, relative to this CSS file.
Step 6 — Add the no-flash script
- Copy
theme/no-flash.jsinto your static folder (in Vite that'spublic/). - Open
index.htmland add this line inside<head>, near the top:
<head>
<script src="/no-flash.js"></script>
...
</head>Important:
- It must be a plain
<script>— do NOT addtype="module"(modules run too late and you'll get a flash). - Re-copy the file after every
npx tte build(or point a build step at it). - What it does: if the visitor picked "green + dark" last time, the page paints green + dark immediately instead of flashing the default first.
Step 7 — Initialize the engine (once)
Open your entry file (main.jsx / main.tsx) and add the init call BEFORE rendering:
import React from "react";
import ReactDOM from "react-dom/client";
import { initializeThemeEngine } from "@jslibkit/tailwind-theme-engine/react";
import { themes, storageKey, modeStorageKey } from "./theme";
import "./index.css";
import App from "./App";
initializeThemeEngine({
initialTheme: "purple", // must match a theme name from your config
themes,
storageKey,
modeStorageKey
});
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);themes,storageKey,modeStorageKeyall come from the generatedtheme/index.js— you don't type them twice.- No provider component is needed. Ever.
Step 8 — Use it in any component
import { useThemeEngine } from "@jslibkit/tailwind-theme-engine/react";
export default function App() {
const { themeName, setTheme, resolvedColorMode, toggleColorMode } = useThemeEngine();
return (
<main className="min-h-screen bg-primary-50 text-primary-950 p-8">
<button
className="bg-primary-500 hover:bg-primary-600 text-white rounded-md px-4 py-2"
onClick={() => setTheme(themeName === "purple" ? "green" : "purple")}
>
Switch theme
</button>
<button
className="bg-tertiary-200 text-tertiary-900 rounded-md px-4 py-2"
onClick={toggleColorMode}
>
{resolvedColorMode === "dark" ? "☀️ Light" : "🌙 Dark"}
</button>
</main>
);
}Step 9 — Run it
npm run devCheck that:
- [ ] Colors show up (not black/transparent).
- [ ] Clicking "Switch theme" changes colors instantly.
- [ ] Clicking the dark toggle flips the page.
- [ ] After picking green + dark, a page refresh does NOT flash purple/light first.
Done. That's the whole setup.
Install with Tailwind v3 instead
Everything above is the same EXCEPT steps 5. Do this instead:
- Step 5a — Open (or create)
tailwind.config.jsand add the generated preset:
import preset from "./theme/tailwind-preset.js";
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
presets: [preset]
};- Step 5b — In your main CSS file, import one theme (no bridge.css needed on v3):
@import "./theme/purple.css";All other steps (1–4, 6–9) are identical.
Everyday workflow (after setup)
Want to change a color or add a theme?
- Edit
tailwind-theme-engine.config.js. - Run
npx tte build. - Re-copy
theme/no-flash.jstopublic/if you changed themes/colors. - Refresh the browser.
That's it. No Tailwind restart tricks, no other files to touch.
Adding a brand-new color name (say accent)?
- Add
accent: "#f97316"to thecolorsof EVERY theme in the config. - Run
npx tte build. - Use
bg-accent-500etc. in your code.
Dark mode in 30 seconds
setColorMode("dark")— force dark. The shade scale flips (50 ↔ 950, 100 ↔ 900...), so all your existing classes just render dark.setColorMode("light")— force light.setColorMode("system")— follow the visitor's OS setting, and update LIVE when the OS switches.toggleColorMode()— flip to the opposite of whatever is showing right now.colorMode— what the user chose:"light" | "dark" | "system".resolvedColorMode— what's actually on screen:"light" | "dark". Use THIS for your sun/moon icon.
Design tip: dark mode looks best when your light shades are actually light and your dark shades actually dark. Look at each theme in both modes once.
UPGRADE GUIDE
Upgrading from v1.x → v2.1
v2 is a cleanup release. Follow every step, in order:
Update the package:
npm install @jslibkit/tailwind-theme-engine@latestCheck your config file exists. v1 tried to create it automatically during install; that's gone (it silently failed on Bun/pnpm anyway). If you don't have
tailwind-theme-engine.config.jsin the project root:npx tte initAdd the storage keys to your config (new, optional but recommended). Open the config and add at the top:
export const storageKey = "app-theme"; export const modeStorageKey = "app-color-mode";⚠️ If your app ALREADY passes a
storageKeytoinitializeThemeEngineoruseThemeEngine, use that SAME string here — otherwise visitors lose their saved choice.Rebuild your themes:
npx tte buildNotes:
- You do NOT need
--forceanymore. Generated files are overwritten automatically. - Shade values will change slightly — the
500shade is now EXACTLY your base color (v1 was a close approximation). This is a visual improvement, not a bug.
- You do NOT need
Switch to the generated bridge/preset so custom color names work:
Tailwind v4 — in your CSS, replace the old package import with the generated one:
/* before */ @import "@jslibkit/tailwind-theme-engine/theme.css"; /* after */ @import "./theme/bridge.css";Tailwind v3 — in
tailwind.config.js, replace the old preset:// before import preset from "@jslibkit/tailwind-theme-engine/preset"; // after import preset from "./theme/tailwind-preset.js";
(If you only use the 7 standard names, the old imports still work — but the generated ones are better because they follow your config.)
Add the no-flash script (new feature — fixes theme flash on refresh):
- Copy
theme/no-flash.jsto yourpublic/folder. - Add
<script src="/no-flash.js"></script>near the top of<head>inindex.html. - Plain script tag only — no
type="module".
- Copy
Update your init call to pull the keys from the generated folder:
import { themes, storageKey, modeStorageKey } from "./theme"; initializeThemeEngine({ initialTheme: "purple", themes, storageKey, modeStorageKey });Check one behavior change in the hook: in v1,
useThemeEngine({ ...options })re-applied its options on every render (this could cause infinite loops). In v2, hook options are used ONCE, only if nothing initialized the engine yet.- If you relied on changing hook options at runtime: call
initializeThemeEngine(newOptions)explicitly instead. - If you just passed options once at the root: nothing to do.
- If you relied on changing hook options at runtime: call
Delete the leftover v1 file (if it's still in your repo clone of this library):
src/cli/postinstall.jsis unused in v2.Run your app and verify the checklist from Step 9 of the install guide.
Upgrading from v2.0 → v2.1
Much smaller. In order:
Update:
npm install @jslibkit/tailwind-theme-engine@latestAdd storage keys to the config (see step 3 above) — optional, but the no-flash script can't restore anything without them.
Rebuild:
npx tte build⚠️ Do this in EVERY project using the package. 2.1 anchors the
500shade to your exact base color, so generated CSS/JS values shift slightly. Rebuilding keeps everything consistent.Add the no-flash script to
public/+index.html<head>(see step 6 above).Optional new toys:
setColorMode("system")— follow the OS, live.resolvedColorMode— what's actually showing (for icons).import { storageKey, modeStorageKey } from "./theme"— single source of truth.
No other code changes are required. v2.0 code runs unchanged on v2.1.
Command cheat sheet
| Command | What it does |
| --- | --- |
| npx tte init | Create the config file (never overwrites without --force). |
| npx tte build | Generate/refresh everything in theme/. |
| npx tte build --out-dir src/theme | Generate somewhere else. |
| npx tte build --config other.config.js | Use a different config file. |
| npx tte build --force | Also overwrite files YOU edited by hand in the output folder. |
| npx tte help | Show help. |
Troubleshooting
My colors don't show at all (everything transparent/black).
- v4: you forgot
@import "./theme/bridge.css";in your CSS. - v3: you forgot the preset in
tailwind.config.js. - Both: you forgot to import one theme CSS file (e.g.
./theme/purple.css).
- v4: you forgot
Classes for my custom color (e.g.
bg-accent-500) don't work.- Run
npx tte buildafter adding the color to the config. - Make sure you import the GENERATED
bridge.css/tailwind-preset.js, not the package's static ones (those only know the 7 standard names).
- Run
The page flashes the default theme on refresh.
- The no-flash script is missing, in the wrong place, or loaded with
type="module". See install Step 6. - The script and your app must use the SAME storage keys. Export the keys from the config and import them from
./themeeverywhere.
- The no-flash script is missing, in the wrong place, or loaded with
Error:
Invalid color value: ... (theme "x", color "y").- The message tells you exactly which theme and color is broken. Usually a typo like
#8b5cf(5 digits).
- The message tells you exactly which theme and color is broken. Usually a typo like
Error:
Unknown theme: "x". Available themes: ....- You called
setThemewith a name that isn't in your config. The error lists the valid names.
- You called
Error:
Refusing to overwrite <file>.- You hand-edited a file inside the output folder. Either move your custom code out of
theme/, or runnpx tte build --forceto regenerate it.
- You hand-edited a file inside the output folder. Either move your custom code out of
Dynamic class names don't work:
bg-${color}-500.- That's a Tailwind rule, not ours: class names must be static strings so Tailwind can see them at build time. Write the full class name.
You passed the wrong thing to the engine.
- Generated scales →
initializeThemeEngine({ themes }). - Raw base colors →
initializeThemeEngine({ themeDefinitions }). - Mixing them up throws a color-validation error.
- Generated scales →
API quick reference
React (@jslibkit/tailwind-theme-engine/react):
initializeThemeEngine(options)— set up once before render. Options:initialTheme,themes,themeDefinitions,includeDefaultThemes,initialColorMode,root,storageKey,modeStorageKey.useThemeEngine()— hook; returns the state below.getThemeEngine()/setTheme(name)/setColorMode(mode)/toggleColorMode()— same things outside React.isThemeEngineInitialized()— boolean.
State: themeName, theme, themes, colorMode ("light" | "dark" | "system"), resolvedColorMode ("light" | "dark"), setTheme, setThemeName, setColorMode, toggleColorMode.
Core (@jslibkit/tailwind-theme-engine):
createTheme({ colors, name? })— base colors → full shade scales.generateScale(color)— one color → one 50–950 scale.applyTheme(theme, root?, colorMode?)— write CSS variables to the DOM.resolveColorMode(mode)—"system"→"light" | "dark".createPreset(colorNames?)— build a Tailwind v3 preset for any names (from/preset).- Plus:
SHADE_STEPS,SEMANTIC_COLOR_NAMES,createThemeCollection,extendThemeDefinitions,defaultThemeDefinitions,defaultThemes,getDefaultThemes.
TypeScript: Theme, ThemeDefinitions, ColorMode, ResolvedColorMode, ThemeEngineState, ThemeEngineOptions and more are exported from the package root and /react.
Contributing / development
git clone <your-repo-url>
cd tailwind-theme-engine
npm install
npm run build # syntax check
npm test # run the test suiteProject layout:
src/core color parsing, scale generation, theme creation
src/runtime CSS variable application (light/dark/system)
src/react shared store + React hook
src/tailwind Tailwind preset
src/themes built-in themes and palettes
src/cli init / build / generate commands
test/ node:test suitesSee UPGRADING.md for the full version-by-version upgrade notes.
License
MIT
