Type-safe design tokens → Tailwind v4 theme codegen + runtime theming.
Define your design tokens once in TypeScript. twgen generates the Tailwind v4
@theme block, the per-theme palette (:root / :root.dark / …), scheme
variants (is-light: / is-dark:), and a safelist — plus typed,
framework-agnostic runtime theme switching. Your token names/values flow from one
source, so the CSS and your types can't drift.
Requires Tailwind CSS v4 — it generates v4 @theme / @custom-variant /
@source syntax. twgen ships as a small suite of scoped packages under
@twgen/*, so you install only what you need:
| Package | What it's for |
|---|---|
@twgen/core |
Define tokens, generate CSS, and switch themes at runtime (createThemeController — a framework-agnostic, dependency-free DOM theme-switcher). The base every other package builds on. |
@twgen/vite |
Vite plugin — regenerates the CSS on build + HMR. Peer: vite. |
@twgen/cli |
The twgen command — generate CSS in CI / non-Vite setups. |
@twgen/react |
Optional typed React theme-switcher hook (a thin useSyncExternalStore wrapper over the core controller). Peer: react. |
Always install @twgen/core; add the others as needed. (Examples use npm — swap
in pnpm add / yarn add / bun add.)
npm install @twgen/core # authoring API + codegen (required)
npm install -D @twgen/vite # Vite plugin
npm install -D @twgen/cli # CLI (or run via npx @twgen/cli)
npm install @twgen/react # React theme-switcher hook (vanilla? use @twgen/core)Tokens are keyed by canonical Tailwind v4 namespace (color, text,
radius, shadow, font, font-weight, tracking, spacing, ease, …).
Share common tokens with defineTokens and reuse them across each theme's
tokens field (assign directly, or spread to override what changes). The
generator emits a constant token as a literal in
@theme, and promotes a token to a per-theme variable only when its value
varies — so any token (color or scale) can differ per theme, but you only pay
for the ones you actually vary.
// src/design/base.tokens.ts — shared fragment
import { defineTokens } from "@twgen/core"
export const base = defineTokens({
text: { md: { rem: "0.875rem", line: "1.375rem" } }, // + optional tracking / weight per size
radius: { md: "0.375rem", full: "calc(infinity * 1px)" },
color: { surface: "#fafafa", text: "#18181b", accent: "#2563eb" },
})// src/design/tokens.ts — the entry the plugin/CLI points at
import { defineThemes } from "@twgen/core"
import { base } from "./base.tokens"
export const themeConfig = defineThemes([
// `default: true` marks the default theme — its tokens become the `:root` base
{ name: "light", scheme: "light", default: true, tokens: base },
// override only what changes; this theme is activated by the `.dark` class
{ name: "dark", scheme: "dark", tokens: { ...base, color: { ...base.color, surface: "#18171d", accent: "#60a5fa" } } },
])
export default themeConfig
// derive your unions: theme names from the aggregate, token names from the fragment
export type Theme = keyof typeof themeConfig.themes
export type ColorToken = keyof typeof base.color
export type TextSize = keyof typeof base.textPeek — what those tokens generate
/* AUTO-GENERATED by twgen — do not edit; regenerated from your tokens. */
@custom-variant is-light (&:where(.scheme-light, .scheme-light *));
@custom-variant is-dark (&:where(.scheme-dark, .scheme-dark *));
:root {
--th-color-surface: #fafafa;
--th-color-accent: #2563eb;
color-scheme: light;
}
:root.dark {
--th-color-surface: #18171d;
--th-color-accent: #60a5fa;
color-scheme: dark;
}
@theme {
/* Colors */
--color-surface: var(--th-color-surface);
--color-text: #18181b;
--color-accent: var(--th-color-accent);
/* Font size (+ paired line-height / letter-spacing / font-weight) */
--text-md: 0.875rem;
--text-md--line-height: 1.375rem;
/* Radius */
--radius-md: 0.375rem;
--radius-full: calc(infinity * 1px);
}
@source inline("bg-{surface,text,accent}");
@source inline("text-{md}");
@source inline("rounded-{md,full}");surface and accent differ between light and dark, so they're promoted to
--th-* variables that swap per theme; text and the scales are constant, so
they stay literals in @theme. You only pay extra CSS for what actually varies.
Prefer a typed unit per file? Wrap a spec in
defineTheme({ ... }), export it, and pass it todefineThemes([...])— it returns the spec unchanged, so the two are interchangeable. (Only setselectoryourself for custom activation, e.g.selector: '[data-theme="dark"]'.)
Every namespace is additive: your tokens extend Tailwind's built-in scale for that
namespace (same-named keys override; the rest of Tailwind's defaults stay). To make a
namespace exclusive — only your tokens, Tailwind's defaults for it removed — list it in
reset (which emits --<ns>-*: initial):
export const tokens = defineThemes(
[ /* …themes… */ ],
{ reset: ["text", "font-weight", "radius", "shadow"] }, // lock these scales
)Reach for this on design-system scales (type, weight, radius, elevation) where a stray
Tailwind default like rounded-3xl or font-thin would let people design off-system.
Vite (recommended) — regenerates on change with HMR:
// vite.config.ts
import { twgen } from "@twgen/vite"
import tailwindcss from "@tailwindcss/vite"
export default defineConfig({
plugins: [twgen(), tailwindcss()], // twgen before tailwind
})/* src/index.css */
@import "tailwindcss";
@import "./design/theme.gen.css"; /* generated; gitignore it */src/design/theme.gen.cssCLI (CI / non-Vite) — the twgen bin comes from @twgen/cli:
npx @twgen/cli gen --tokens src/design/tokens.ts --out src/design/theme.gen.css
# or, if installed: twgen gen --tokens … --out …The engine lives in @twgen/core as createThemeController — framework-agnostic
and dependency-free. @twgen/react wraps it in a hook; use the controller
directly for vanilla JS, or as the base for any other framework binding.
// src/stores/themeStore.ts
import { createThemeStore } from "@twgen/react"
import { themeConfig } from "@/design/tokens"
export const useTheme = createThemeStore(themeConfig) // pass the whole configThe hook returns currentTheme, scheme (the active theme's light/dark),
availableThemes, and setTheme — you build the control (a light/dark toggle,
a dropdown, whatever):
const { currentTheme, availableThemes, setTheme, scheme } = useTheme()
const isDark = scheme === "dark"
// dropdown
<select value={currentTheme} onChange={(e) => setTheme(e.target.value)}>
{availableThemes.map((t) => <option key={t}>{t}</option>)}
</select>Same engine, no framework. createThemeController returns
getSnapshot() / setTheme() / subscribe() plus availableThemes:
import { createThemeController } from "@twgen/core"
import { themeConfig } from "@/design/tokens"
const theme = createThemeController(themeConfig)
const btn = document.querySelector("#toggle")
btn.addEventListener("click", () =>
theme.setTheme(theme.getTheme() === "dark" ? "light" : "dark"),
)
theme.subscribe(() => {
btn.textContent = theme.getSnapshot().scheme === "dark" ? "🌙" : "☀️"
})Either way, the controller reads the saved choice (localStorage, key "theme")
— falling back to the OS prefers-color-scheme — and applies the theme +
scheme-* classes to <html> the moment it's constructed, so a client-rendered
app paints the right theme before your framework mounts. Server-rendering?
The server markup has no class yet, so add a tiny inline <head> script that
sets it from the same localStorage/OS logic to avoid a flash of the wrong theme
during hydration.
A tiny conditional class joiner is exported for convenience:
import { cn } from "@twgen/core"
<div className={cn("rounded-md border border-border", selected && "border-accent bg-accent/10")} />Your tokens autocomplete for free in plain className="..." strings — twgen
emits them as real Tailwind @theme variables, so the stock Tailwind LSP
suggests bg-surface, text-accent, etc. with no setup, in any editor.
But the LSP only reads class names inside string attributes. Once you compose
conditionally — selected && "bg-accent" — the classes sit inside a function
call and autocomplete goes quiet. Registering cn as a class function restores
it: the LSP treats cn(...) args as class lists, so you keep the same token
IntelliSense in conditional logic.
// .vscode/settings.json
{ "tailwindCSS.classFunctions": ["cn"] }classFunctions isn't a VS Code feature — it's an option of the
Tailwind CSS language server (tailwindcss-language-server), which every
editor's Tailwind integration drives. So the same setting works everywhere;
only where you put it changes:
| Editor | Where | Value |
|---|---|---|
| JetBrains (WebStorm / IntelliJ) | Settings → Languages & Frameworks → Style Sheets → Tailwind CSS (config box) | { "classFunctions": ["cn"] } |
Neovim (nvim-lspconfig) |
tailwindcss.setup{} |
settings = { tailwindCSS = { classFunctions = { "cn" } } } |
Zed (settings.json) |
lsp.tailwindcss-language-server.settings |
{ "classFunctions": ["cn"] } |
Sublime Text (LSP-tailwindcss) |
settings |
{ "tailwindCSS.classFunctions": ["cn"] } |
| Helix / Emacs | LSP init options for tailwindcss-language-server |
classFunctions: ["cn"] |
Needs a recent language server (≥ v0.12). On older versions, use the portable regex fallback instead — works in any editor:
{ "tailwindCSS.experimental.classRegex": ["cn\\(([^)]*)\\)"] }
- Tokens are theme-reactive with no prefix.
bg-surfaceresolves to whichever palette is active — switch theme and it reskins automatically. is-light:/is-dark:apply by the active theme's scheme, so they fire for every theme of that scheme (not just one nameddark). Stockdark:is left untouched.
<div className="bg-surface is-dark:shadow-lg is-light:border" />These variants key off a scheme-light / scheme-dark class on <html>. The
runtime controller (and the React hook that wraps it) adds it for you; if you use
the codegen without the runtime, set that class yourself.
Contributions are welcome! See CONTRIBUTING.md for local setup, the dev loop, and the architecture boundaries to respect.
MIT © Hunter Davis