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.
Basic Usage
Section titled “Basic Usage”npx @csszyx/cli migrate src/ # migrate all JSX/TSX/HTML under src/npx @csszyx/cli migrate --dry-run # preview changes without writing filesnpx @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.
Related: scan for sz issues
Section titled “Related: scan for sz issues”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:
npx @csszyx/cli check # scan the whole projectnpx @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.
1. Boolean value-sugar was removed
Section titled “1. Boolean value-sugar was removed”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`.2. CSS-property-name keys are not sz keys
Section titled “2. CSS-property-name keys are not sz keys”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:
// beforeimport { _sz, _szIf, _szSwitch } from "@csszyx/runtime";_sz("base", _szIf(isActive, "active", "inactive"));_sz("base", _szSwitch(size, { sm: "p-2", lg: "p-6" }));
// afterimport { _sz } from "@csszyx/runtime";_sz("base", isActive ? "active" : "inactive");_sz("base", { sm: "p-2", lg: "p-6" }[size]);Understanding the Migration Report
Section titled “Understanding the Migration Report”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 line | What it means | What to do |
|---|---|---|
classNames converted: N | Recognized utilities moved into sz={...} | Nothing |
classNames kept on components (no sz support): N | className on a custom component (<Card className="…" />), not a DOM element. sz only applies to host/DOM elements, so these are left untouched | Convert by hand if the component forwards styles; otherwise leave as-is |
classNames skipped (dynamic): N | A clsx / ternary / template-literal expression migrate could not safely rewrite | Review 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 here | Feed into --audit → .csszyx-todo.json and resolve |
Two behaviors to call out explicitly:
- Unknown static classes stay put. A class like
sport-neonis a regular static CSS class, not an atomic utility — migrate leaves it inclassNameand reports it as unrecognized. It is never folded into a dynamic_szMergecall 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 theUnrecognized classeslist — nothing slips through silently, so the audit map stays complete.
Audit — Discover Unrecognized Classes
Section titled “Audit — Discover Unrecognized Classes”Before migrating, run an audit to see what csszyx cannot automatically convert:
npx @csszyx/cli migrate --auditThis 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"}Resolving the Todo Map
Section titled “Resolving the Todo Map”Edit .csszyx-todo.json to tell csszyx what to do with each class:
| Value | Meaning |
|---|---|
"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 / false | Same as "sz:todo" (backwards compat) |
--resolve-todos — Apply the Resolution Map
Section titled “--resolve-todos — Apply the Resolution Map”npx @csszyx/cli migrate --resolve-todos .csszyx-todo.jsonReads .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.
Display Utilities
Section titled “Display Utilities”When migrating Tailwind display classes, csszyx emits the canonical display
property instead of boolean sugar:
| Tailwind | Migrated 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 propertiesclassName="flex flex-1"// → sz={{ display: 'flex', flex: '1' }}
// Unsafe: two display values in the same scopeclassName="block flex"// → stays unresolved for manual review--inject-todos — Mark Unresolved Classes in Code
Section titled “--inject-todos — Mark Unresolved Classes in Code”npx @csszyx/cli migrate --inject-todosInserts {/* @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.
Full Workflow
Section titled “Full Workflow”# 1. Dry run — preview what will changenpx @csszyx/cli migrate --dry-run
# 2. Audit — find unrecognized classesnpx @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 mapnpx @csszyx/cli migrate --resolve-todos .csszyx-todo.json
# 5. Re-audit if anything remains unresolvednpx @csszyx/cli migrate --auditHTML Files
Section titled “HTML Files”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 ❌
Injecting the runtime
Section titled “Injecting the runtime”# 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 URLnpx @csszyx/cli migrate public/ --inject-runtime cdn --cdn-url https://my-cdn.com/csszyx.js
# Custom local pathnpx @csszyx/cli migrate public/ --inject-runtime local --local-path ./vendor/csszyx-runtime.jsThe runtime script tag is injected before </body>.
FOUC prevention
Section titled “FOUC prevention”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.
--braces flag
Section titled “--braces flag”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.
TypeScript Types
Section titled “TypeScript Types”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>