vanillify
v1.1.0
Published
Tailwind CSS to vanilla CSS converter powered by Tailwind CSS and oxc-parser
Downloads
36
Readme
What is Vanillify?
Vanillify converts Tailwind utility classes in JSX/TSX files to vanilla CSS. It uses Tailwind v4's native compile().build() API for real CSS output and oxc-parser for proper AST-based class extraction.
No regex. No lookup tables. The CSS you get is generated by Tailwind's own compilation pipeline — the same engine that powers production builds, not a hand-maintained mapping that drifts from reality.
Before / After
Input - button.tsx
import { component$ } from "@qwik.dev/core";
export const Button = component$(() => {
return (
<button class="bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600">
Click me
</button>
);
});Output - button.vanilla.tsx
import { component$ } from "@qwik.dev/core";
import "./button.vanilla.css";
export const Button = component$(() => {
return <button class="node0">Click me</button>;
});Output - button.vanilla.css
.node0 {
border-radius: 0.5rem;
background-color: rgb(59, 130, 246);
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: rgb(255, 255, 255);
}
.node0:hover {
background-color: rgb(37, 99, 235);
}Each element gets an indexed class name (.node0, .node1, ...) with its full CSS isolated per node.
Installation
npm install vanillifyUse Cases
- Framework examples - Generate vanilla CSS versions of your components for documentation, tutorials, or sharing across Qwik, React, Preact, Solid and other frameworks without requiring Tailwind as a dependency
- Migrate away from Tailwind - Convert an entire project from Tailwind to vanilla CSS, producing portable stylesheets you can take anywhere, no build tool lock-in
- Audit your styles - See exactly what CSS your Tailwind classes produce, useful for debugging specificity issues or understanding generated output
- Ship framework-agnostic libraries - Strip Tailwind from your component library before publishing so consumers don't need Tailwind in their stack
Semantic Naming with LLMs
Right now vanillify uses indexed class names (.node0, .node1, ...). The intention is to support hooking into LLM models to generate semantic class names automatically, so .node0 becomes .primary-button or .card-header based on the component context.
This would let you go from Tailwind utility classes to clean, human-readable CSS without manually naming anything.
Local models, such as Gemma 4B can be used in workflows at no cost.
Usage - Programmatic API
import { convert } from "vanillify";
const source = `import { component$ } from "@qwik.dev/core";
export const Card = component$(() => {
return <div class="flex p-4 bg-white rounded-lg">Hello</div>
});`;
const { component, css, warnings } = await convert(source, "Card.tsx");| Return field | Description |
| ------------ | ----------------------------------------------------------------------- |
| component | Rewritten source with indexed class names and a CSS import |
| css | Generated vanilla CSS |
| themeCss | Extracted :root CSS variables from theme layer |
| warnings | Array of { type, message, location } for dynamic or unmatched classes |
convert() is a pure async function - no file I/O, no side effects.
Options
Pass any Tailwind CSS directives via the css option — Tailwind handles @theme, @custom-variant, and all other directives natively:
const result = await convert(source, "Card.tsx", {
css: `
@theme { --color-brand: #ff0000; }
@custom-variant ui-open (&[data-open]);
@custom-variant ui-checked (&[data-checked]);
`,
});CSS Modules
const result = await convert(source, "Card.tsx", {
outputFormat: "css-modules",
});
// result.component uses {styles.node0} instead of "node0"
// result.css is identical (use as .module.css)
// result.classMap maps indexed namesUsage - CLI
# Convert a single file
npx vanillify "src/Button.tsx"
# Convert with glob patterns
npx vanillify "src/components/*.tsx"
# Specify output directory
npx vanillify "src/Button.tsx" -o dist
# Pass CSS with @theme, @custom-variant, etc.
npx vanillify "src/**/*.tsx" -c tailwind.css
# CSS Modules output
npx vanillify "src/**/*.tsx" -f css-modules| Flag | Alias | Description |
| ---------- | ----- | -------------------------------------------------------------- |
| --outDir | -o | Output directory (default: alongside input files) |
| --css | -c | Path to CSS file with @theme, @custom-variant, etc. |
| --format | -f | Output format: vanilla (default) or css-modules |
The CLI outputs .vanilla.css and .vanilla.tsx (or .vanilla.jsx) files, preserving the original file extension. With --format css-modules, it outputs .module.css and .module.tsx instead.
How It Works
Vanillify runs a four-step pipeline:
- Parse - JSX/TSX source is parsed into an AST using oxc-parser (Rust-powered, the same parser behind Rolldown)
- Extract -
classNameandclassattribute values are extracted by walking the AST with oxc-walker. Dynamic expressions with string literals are rewritten; unresolvable parts emit warnings. - Generate - Extracted class names are fed to Tailwind v4's native
compile().build()API to produce real CSS - Rewrite - The original source is rewritten with indexed class names (
.node0,.node1, ...) and a CSS import is prepended
Each step is a pure function. The full pipeline is a single await convert(source, filename) call.
Dynamic Patterns
Vanillify v1.1 rewrites string literals inside dynamic class expressions. Each recognized string literal gets a scoped class name and CSS — the rest of the expression structure is preserved unchanged.
Supported Patterns
Ternary expressions — both branches are rewritten:
class={active ? "flex gap-4" : "hidden"}
// becomes: class={active ? "node0" : "node1"}Logical AND expressions — the string literal on the right-hand side is rewritten:
className={isOpen && "p-4 bg-white"}
// becomes: className={isOpen && "node0"}Function call arguments (clsx, cn, classnames, etc.) — all string literal args are rewritten:
className={clsx("flex", "gap-4")}
// becomes: className={clsx("node0", "node1")}Object keys (quoted) — quoted string keys in function call arguments are rewritten:
className={clsx({ "flex gap-4": isActive })}
// becomes: className={clsx({ node0: isActive })}Object keys (unquoted identifiers) — identifier keys that are valid Tailwind tokens are rewritten:
className={clsx({ hidden: cond })}
// becomes: className={clsx({ node0: cond })}twMerge unwrapping — twMerge() calls are unwrapped and the import is removed if unused:
className={twMerge("flex gap-4")}
// becomes: className={"node0"} (import removed if no other references)
className={twMerge("flex", "gap-4")}
// becomes: className={"node0"} (args joined, single scoped name)twMerge limitations:
- Only
twMergeimported from"tailwind-merge"is detected. Aliased imports (import { twMerge as tm }) work, but re-exports through your own utility file (import { tm } from "./utils") are not traced.- All arguments must be string literals. If any argument is a variable or expression (e.g.,
twMerge("flex", someVar)), the entire call is left unchanged — no partial unwrapping.- Only
twMergecalls directly inside aclassName/classattribute are unwrapped. Calls assigned to variables (const cls = twMerge(...)) are not affected.- The
tailwind-mergeimport is removed only when no other references to the imported name remain anywhere in the file.
CSS Modules — all patterns above work with outputFormat: "css-modules". The syntax adapts per context:
className={clsx({ "flex gap-4": isActive })}
// becomes: className={clsx({ [styles.node0]: isActive })}Out of Scope
These patterns are not handled and will not be rewritten:
| Pattern | Reason |
|---------|--------|
| Variable references (className={myVar}) | Requires data-flow analysis across scopes |
| Computed class tokens ("text-" + size) | Cannot determine the final Tailwind token statically |
| Template literals with interpolation (`flex ${size}`) | Span replacement corrupts template delimiters |
| Wrapper function tracing (cn wrapping twMerge) | Interprocedural analysis, beyond static AST |
| Runtime class conflict resolution | twMerge semantics are unnecessary with vanilla CSS |
Warnings
convert() returns a warnings array. Each entry has type, message, and location.
| Warning type | When emitted |
|--------------|--------------|
| unmatched-class | A class string was passed to Tailwind but produced no CSS output |
| dynamic-class | A className expression was only partially rewritten because it contains variable references or other unresolvable parts — includes line and column of the expression |
A fully-rewritten expression (all string literals resolved) emits no dynamic-class warning.
Why Not Regex?
Existing Tailwind-to-CSS converters use regex matching or lookup tables to map class names to CSS. This breaks on:
- Stacked variants (
hover:focus:bg-blue-500) - Arbitrary values (
bg-[#1a1a2e],w-[calc(100%-2rem)]) - Dynamic or conditional class expressions
Vanillify sidesteps all of this. The AST parser handles extraction correctly regardless of syntax complexity, and Tailwind's compilation engine produces the same CSS it would in a real build. If Tailwind supports a utility, vanillify converts it accurately.
Requirements
- Node.js >= 20
Tech Stack
| Component | Technology |
| ---------- | ---------------------------------------------------------------------------- |
| CSS engine | tailwindcss v4 compile() API |
| Parser | oxc-parser |
| AST walker | oxc-walker |
| CLI | citty + consola |
| Bundler | tsdown |
| Tests | vitest |
