Skip to content

Repository files navigation

twgen

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.

Install

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)

1. Define tokens

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.text
Peek — 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 to defineThemes([...]) — it returns the spec unchanged, so the two are interchangeable. (Only set selector yourself for custom activation, e.g. selector: '[data-theme="dark"]'.)

Additive by default; lock a scale with reset

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.

2. Generate CSS

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.css

CLI (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 …

3. Runtime theme switching

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.

React

// src/stores/themeStore.ts
import { createThemeStore } from "@twgen/react"
import { themeConfig } from "@/design/tokens"

export const useTheme = createThemeStore(themeConfig) // pass the whole config

The 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>

Vanilla

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.

cn helper

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"] }

Other editors

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\\(([^)]*)\\)"] }

Variants

  • Tokens are theme-reactive with no prefix. bg-surface resolves 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 named dark). Stock dark: 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.

Contributing

Contributions are welcome! See CONTRIBUTING.md for local setup, the dev loop, and the architecture boundaries to respect.

License

MIT © Hunter Davis

About

Type-safe design tokens → Tailwind v4 theme codegen + runtime theming.

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages