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.

The same CLI ships csszyx check, a static diagnostic pass that scans every source file for unknown or aliased sz keys (the build only warns about them lazily, as routes are opened). It prints each issue with its file:line and exits non-zero, so it works as a CI gate or pre-commit step:

Terminal window
npx @csszyx/cli check # scan the whole project
npx @csszyx/cli check --pattern "src/**/*.tsx"

Breaking changes: single-way keys + closed type

Section titled “Breaking changes: single-way keys + closed type”

Two breaking changes affect how sz objects are written. csszyx migrate rewrites old code to the new form automatically, and dev-mode warnings point at the exact fix. Both changes enforce the same principle: one canonical key per CSS property, no aliases.

Keys that were a boolean alias for a value of a single, mutually-exclusive CSS property no longer accept { key: true }. They emit nothing and warn in dev. Write the canonical { property: 'value' } form instead.

Before (removed)After (canonical)CSS property
{ flex: true }{ display: 'flex' }display
{ block: true } / { hidden: true }{ display: 'block' } / { display: 'none' }display
{ absolute: true } / { relative: true }{ position: 'absolute' } / { position: 'relative' }position
{ invisible: true }{ visibility: 'hidden' }visibility
{ isolate: true }{ isolation: 'isolate' }isolation
{ uppercase: true }{ textTransform: 'uppercase' }text-transform
{ italic: true }{ fontStyle: 'italic' }font-style
{ underline: true }{ decoration: 'underline' }text-decoration-line
{ antialiased: true }{ fontSmoothing: 'grayscale' }font-smoothing

The dev warning is precise:

[csszyx] "flex" boolean sugar was removed. Use { display: 'flex' } instead, or run `csszyx migrate`.

sz keys are csszyx’s short, Tailwind-aligned names — not CSS property names. A CSS-property name like padding or backgroundColor is not a valid sz key; it emits nothing and warns with the canonical key to use.

CSS-property name (not a key)Canonical sz key
{ padding: 4 }{ p: 4 }
{ marginTop: 2 }{ mt: 2 }
{ backgroundColor: 'blue-500' }{ bg: 'blue-500' }
{ width: 'full' }{ w: 'full' }
{ fontSize: 'lg' }{ text: 'lg' }
{ borderRadius: 'lg' }{ rounded: 'lg' }
[csszyx] Use the canonical key "bg" instead of "backgroundColor".

3. The sz type is closed — typos fail at compile time

Section titled “3. The sz type is closed — typos fail at compile time”

The sz prop type no longer has an open index signature, so an unknown key (a typo or a legacy CSS-property name) is a TypeScript error caught by tsc --noEmit in CI — not a silent dead class at runtime.

<div sz={{ bgColor: 'red-500' }} /> // ❌ tsc error — unknown key; use { bg: 'red-500' }

To deliberately use a brand-new Tailwind utility csszyx has no key for yet, opt out with @ts-expect-error (a conscious decision; the runtime still emits the class):

{/* @ts-expect-error - forward-compat utility not yet in csszyx */}
<div sz={{ someNewUtility: 'x' }} />

Arbitrary variants (@container, min-[320px], [&>span]) and custom breakpoints declared in @theme { --breakpoint-tablet: … } still type-check — see Sz Props Basics.

4. _szIf and _szSwitch were removed — use plain JS

Section titled “4. _szIf and _szSwitch were removed — use plain JS”

These two runtime helpers are gone from @csszyx/runtime. Compose _sz with a plain conditional or object lookup instead — the same result with nothing extra to import. Importing them now fails the build, so update call sites:

// before
import { _sz, _szIf, _szSwitch } from "@csszyx/runtime";
_sz("base", _szIf(isActive, "active", "inactive"));
_sz("base", _szSwitch(size, { sm: "p-2", lg: "p-6" }));
// after
import { _sz } from "@csszyx/runtime";
_sz("base", isActive ? "active" : "inactive");
_sz("base", { sm: "p-2", lg: "p-6" }[size]);

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' }

Boolean sugar such as { flex: true } was removed; use { display: 'flex' }. 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>