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,mocks}/**"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 on an error finding, the level unknown and aliased keys have by
default, 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] removed-key: The boolean shorthand `flex` was removed, and nothing is emitted for it. help: write `{ display: 'flex' }` instead, or run `csszyx migrate`. docs: https://csszyx.com/e/removed-key2. 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] canonical-key: `backgroundColor` is an alias of `bg`, and nothing is emitted for it. help: write `bg`. docs: https://csszyx.com/e/canonical-key3. 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]);5. font-variant-numeric, touch-action and contain take one key per group (0.18.0)
Section titled “5. font-variant-numeric, touch-action and contain take one key per group (0.18.0)”tabularNums: true became numSpacing: 'tabular', and touch: 'pan-x' became
touchPanX: 'x'. --keys-only rewrites both. The full tables, the warnings and
how migrate settles two old keys of one group are in
Upgrading to 0.18.
What --keys-only leaves for the build
Section titled “What --keys-only leaves for the build”--keys-only rewrites the keys of an sz object written in the sz prop: the
object itself, its nested variants (md, hover, group, data, aria, …),
and an object chosen by a ternary or &&/||/??
(sz={c ? { touch: 'pan-x' } : { … }}). It leaves these as they are:
| Left as written | Why |
|---|---|
An object inside szv(…), _sz(…) or another call | Only the sz prop is read |
An object inside an sz array (sz={[base, c && { … }]}) | Later layers override earlier ones, so renaming one layer alone can change which value renders |
| HTML files | --keys-only skips them; the CDN runtime reports a replaced key or a moved value in the browser console |
A ternary with a runtime branch (tabularNums: c ? true : v) | The codemod cannot spell a value it does not know |
A ternary that mixes a moved value with a stand-alone keyword (touch: c ? 'pan-x' : 'auto') | Splitting it puts runtime values on both sides of one family, which the build reports |
The keys of a spread object (base in { ...base, p: 2 }) | Only the object literal in the prop is read |
Run csszyx check after migrating to list what is left in static sz props and
szv() definitions. A replaced key, a moved value or a removed key
(fontVariant) the build cannot see statically, inside an object built at
runtime, is reported in the browser console when it renders, in production too.
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.
On a file an earlier pass already migrated, an element reads
className="mystery" sz={{ … }}. The resolve pass merges the classes the map
decides into that existing sz object and drops them from className; a key the
sz object already sets is never overridden — the element is left as is and
reported, so you resolve it by hand. Classes the map does not decide stay in
className and keep their @sz-todo marker.
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 --auditA Tailwind prefix
Section titled “A Tailwind prefix”When the project’s stylesheet sets a prefix, migrate reads it off each class.
With @import "tailwindcss" prefix(tw):
// before<div className="tw:p-4 tw:hover:flex p-2" />
// after<div className="p-2" sz={{ p: 4, hover: { display: 'flex' } }} />The build writes the prefix back (tw:p-4 tw:hover:flex). A class without it,
p-2 here, stays in className as written: that Tailwind serves no unprefixed
utility, so it styled nothing before and still does not. A Tailwind class string
in the resolution map is read the same way. Stylesheets that set different
prefixes give no prefix to read, as in csszyx check, and migrate says so before
it starts; with prefix-disagreement set to off in csszyx.config, the first
entry’s prefix is read.
What migrate needs
Section titled “What migrate needs”migrate runs on the native engine and needs the @csszyx/core-<platform>
package for your platform. npm installs it for you on Linux, macOS and Windows,
for x64 and arm64 — it is an optional dependency resolved at install time.
There is no second implementation behind it. On a platform with no prebuilt binary, or after an install that skipped optional packages, migrate stops before touching any file:
[csszyx] native-migrate-unavailable: `@csszyx/core-linux-x64-gnu` is not installed, so migrate cannot run. help: reinstall without skipping optional dependencies, which installs `@csszyx/core-linux-x64-gnu` with `@csszyx/core`. note: no file was changed; migrate runs on the native engine only, and builds and the runtime do not need it. docs: https://csszyx.com/e/native-migrate-unavailableBuilding and the runtime are unaffected by this — those fall back to the WASM
build of the engine that ships inside @csszyx/core. migrate is the one command
that cannot, because the WASM artifact is built without it.
HTML 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>