Skip to content

Migrate from Tailwind

The csszyx migrate CLI command converts className= (JSX/TSX) or class= (HTML) attributes to sz= props automatically. It handles static strings, clsx calls, ternary expressions, and template literals.

The CLI ships as a separate package, @csszyx/cli. Run it one-off with npx @csszyx/cli (or pnpm dlx @csszyx/cli), or install it as a dev dependency (pnpm add -D @csszyx/cli) and then call csszyx directly.

Terminal window
npx @csszyx/cli migrate src/ # migrate all JSX/TSX/HTML under src/
npx @csszyx/cli migrate --dry-run # preview changes without writing files
npx @csszyx/cli migrate --ignore "**/*.test.tsx,**/fixtures/**"
npx @csszyx/cli migrate --pattern "src/components/**/*.tsx"

Migration logs are written to .csszyx/logs/. Add .csszyx/ to .gitignore.

After each run, migrate prints a summary (also written to .csszyx/logs/) grouped by what happened to each class. The buckets tell you exactly what, if anything, still needs manual attention:

Report lineWhat it meansWhat to do
classNames converted: NRecognized utilities moved into sz={...}Nothing
classNames kept on components (no sz support): NclassName on a custom component (<Card className="…" />), not a DOM element. sz only applies to host/DOM elements, so these are left untouchedConvert by hand if the component forwards styles; otherwise leave as-is
classNames skipped (dynamic): NA clsx / ternary / template-literal expression migrate could not safely rewriteReview by hand — simple dynamic cases are converted automatically
Unrecognized classes (N): …Classes with no Tailwind/sz mapping (e.g. a project class like sport-neon). They stay in className and are listed hereFeed into --audit.csszyx-todo.json and resolve

Two behaviors to call out explicitly:

  • Unknown static classes stay put. A class like sport-neon is a regular static CSS class, not an atomic utility — migrate leaves it in className and reports it as unrecognized. It is never folded into a dynamic _szMerge call alongside converted classes.
  • Unrecognized classes inside skipped dynamic patterns are still surfaced. When a clsx/ternary/template expression is skipped, any unmapped classes inside it still appear in the Unrecognized classes list — nothing slips through silently, so the audit map stays complete.

Before migrating, run an audit to see what csszyx cannot automatically convert:

Terminal window
npx @csszyx/cli migrate --audit

This scans your codebase and writes .csszyx-todo.json without touching any source files. Each entry starts as "sz:todo":

{
"btn": "sz:todo",
"custom-card": "sz:todo",
"animate-spin-slow": "sz:todo"
}

Edit .csszyx-todo.json to tell csszyx what to do with each class:

ValueMeaning
"sz:todo"Not yet decided — skip, surface in reports
"sz:keep"Keep in className, acknowledged as intentional
"sz:remove"Drop from output entirely
{ p: 4, bg: 'blue-500' }Direct sz object — merged into sz prop
"p-4 bg-blue-500"Tailwind string — auto-converted to sz
null / falseSame as "sz:todo" (backwards compat)

--resolve-todos — Apply the Resolution Map

Section titled “--resolve-todos — Apply the Resolution Map”
Terminal window
npx @csszyx/cli migrate --resolve-todos .csszyx-todo.json

Reads .csszyx-todo.json and applies it during migration. Classes mapped to sz:keep stay in className; sz:remove entries are dropped; sz objects and Tailwind strings are converted.

--resolve-todos is read-only — it never writes to the todo file. Still-unresolved sz:todo entries appear in the console and log only.

When migrating Tailwind display classes, csszyx emits the canonical display property instead of boolean sugar:

TailwindMigrated sz
block{ display: 'block' }
inline{ display: 'inline' }
flex{ display: 'flex' }
inline-flex{ display: 'inline-flex' }
hidden{ display: 'none' }

Manual sz authoring can still use sugar such as { flex: true }, but the migration output is canonical so duplicate display classes share one object key surface. If a class list contains conflicting display utilities in the same variant scope, csszyx fails closed and leaves those classes in className / todo output instead of guessing which one should win.

// Safe: display and flex shorthand are different CSS properties
className="flex flex-1"
// → sz={{ display: 'flex', flex: '1' }}
// Unsafe: two display values in the same scope
className="block flex"
// → stays unresolved for manual review

--inject-todos — Mark Unresolved Classes in Code

Section titled “--inject-todos — Mark Unresolved Classes in Code”
Terminal window
npx @csszyx/cli migrate --inject-todos

Inserts {/* @sz-todo: classname1, classname2 */} comments above JSX elements that still have unrecognized classes — a visual marker so you can grep or skim the diff to find what needs attention.

When --resolve-todos is active, --inject-todos is automatically enabled for any still-unresolved classes.

Terminal window
# 1. Dry run — preview what will change
npx @csszyx/cli migrate --dry-run
# 2. Audit — find unrecognized classes
npx @csszyx/cli migrate --audit
# → writes .csszyx-todo.json
# 3. Edit .csszyx-todo.json
# → set "sz:keep", "sz:remove", or direct sz objects for each entry
# 4. Apply with resolution map
npx @csszyx/cli migrate --resolve-todos .csszyx-todo.json
# 5. Re-audit if anything remains unresolved
npx @csszyx/cli migrate --audit

For plain HTML files (no JSX build step), csszyx converts class="..." to sz="..." attributes. A runtime script is needed at page load to process them — the migration command can inject it for you.

By default (csszyx migrate public/), the command:

  • Converts class="..."sz="..."
  • Injects FOUC prevention CSS into <head>
  • Does not inject a runtime script ❌
Terminal window
# CDN (default URL: https://cdn.csszyx.com/runtime.js)
npx @csszyx/cli migrate public/ --inject-runtime cdn
# Local file (default path: csszyx-runtime.js, relative to each HTML file)
npx @csszyx/cli migrate public/ --inject-runtime local
# Custom CDN URL
npx @csszyx/cli migrate public/ --inject-runtime cdn --cdn-url https://my-cdn.com/csszyx.js
# Custom local path
npx @csszyx/cli migrate public/ --inject-runtime local --local-path ./vendor/csszyx-runtime.js

The runtime script tag is injected before </body>.

Enabled by default. Injects this block before </head>:

<style>
/* csszyx: hide [sz] elements until runtime processes them */
[sz] { visibility: hidden; }
body.sz-ready [sz] { visibility: visible; }
</style>

Pass --no-fouc to skip this if you are managing the loading transition yourself.

Controls the format of the generated sz attribute value.

Without --braces (default — bare object contents):

<div sz="p: 4, bg: 'blue-500'"></div>

With --braces (full object syntax):

<div sz="{ p: 4, bg: 'blue-500' }"></div>

Use --braces if your HTML is processed by a template engine that expects full object literal syntax.

The todo map types are exported for use in custom tooling:

import type { CsszyxTodoEntry, CsszyxTodoMap } from '@csszyx/cli';
// CsszyxTodoEntry = Record<string, unknown> | string | null | false
// CsszyxTodoMap = Record<string, CsszyxTodoEntry>