Skip to content

Plugin Config

CSSzyx is configured by passing options directly to the plugin. There is no standalone csszyx.config.ts file — all config lives in your bundler config.

vite.config.ts
import csszyx from 'csszyx/vite';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
...csszyx({
development: {
debug: true,
},
production: {
mangle: true,
},
build: {
astBudgetLimit: 50_000, // optional, default 50k
cache: true, // optional, enabled by default
},
}),
tailwindcss(),
react(),
],
});
import type { PartialCsszyxConfig } from "@csszyx/types";
const csszyxOptions: PartialCsszyxConfig = {
exclude: ["src/generated/**"],
development: { debug: true },
production: { mangle: true },
};

Controls development mode behavior.

interface DevelopmentConfig {
strictMode: boolean; // Treat warnings as errors
debug: boolean; // Enable debug logging
}
OptionDefaultDescription
strictModefalseTreat all CSSzyx warnings as build errors
debugfalseEnable verbose debug logging

For per-element hydration recovery, use the szRecover JSX attribute on individual elements rather than a global flag — see SSR & Hydration → Recovery Tokens.

Controls production build behavior.

interface ProductionConfig {
mangle: boolean; // Obfuscate class names (z, y, x, ...) — opt-in, default false
mangleVars: boolean; // Opt into dynamic sz CSS variable mangling and hoisting
mangleVarHoistMaxDepth: number; // Max cascade depth for mangleVars hoisting
mangleGlobalVars?: GlobalVarMangleConfig; // Explicit global token alias tier
hydrationCensus?: boolean; // Ship the class census in the HTML (default true, mangling only)
mangleDebugGlobal?: boolean; // Expose the registry as window.__csszyx (default false)
minify: boolean; // Minify output
}

Two things, both about names rather than bytes.

A class name stops looking like an API. Tokens are allocated over the sorted census of classes the build found, so a class added earlier in that order shifts every token after it and one added later shifts none — there is no rule for when a token changes, only no promise that it will not. A browser extension, a host page, another team’s E2E selectors can still latch onto a token; what they cannot have is a contract, and a selector that keeps matching may be matching a different element. An opaque name does not invite that dependency the way a readable one does. It is not confidentiality — the stylesheet ships, so .z{padding:1rem} reads plainly, and the census in the page maps every token back to the name it came from.

The DOM tree is readable in devtools. Atomic utilities plus transition, animation and variant states give one element a className of dozens of names, and in the Elements panel that line wraps over several rows and buries the structure — the cost lands on whoever is debugging something that is not styling. Mangled, each element carries a handful of short tokens and the tree fits the panel again. When the question does turn out to be styling, szDecode and the census name the class the token came from.

The runtime mangle map is what lets szr, szv, szcn, and szDecode speak the same class names as the mangled CSS. It is registered from a module inside your JS bundle — a virtual module on Vite/Rollup, a generated .csszyx/mangle-runtime.mjs on webpack.

Every lane attaches it ahead of your app code rather than relying on which files import the runtime: Vite and Rollup put it on the HTML entry, webpack prepends it to every entrypoint. So a pre-compiled component library under node_modules that calls szr itself is covered, and so is a require() or a dynamic import() that no source scan would see.

There is nothing to configure, and the built HTML never carries an executable inline <script>, so a strict script-src 'self' policy needs no exception. See Security → CSP.

The hydration census is separate: the inert <script type="application/json"> tag and the checksum attribute always ship (hydration verify reads them from the DOM), and CSP does not evaluate a data block.

production: { mangle: true, hydrationCensus: false }

Every mangled build writes the class census into the built HTML, as an inert <script type="application/json"> listing each original class name against the token that replaced it. It is what lets someone read a deployed page in devtools — see Reading the map on a page you cannot rebuild — and it is what loadMangleMapFromDOM and verifyMangleMapIntegrity read.

It follows mangle. A build that renames nothing has nothing to map, so no census is written whatever this option says, and a page that carries the readable names carries no tag either.

Set it false on a build that DOES mangle and the tag is gone. That is for one reader: an organisation that inventories every <script> element in its pages and will not carry this one — the browser never objects to it, but a review process can. What it costs is the ability to decode a page you have already deployed, and the two functions above answer “nothing to read” instead. The hydration checksum is unaffected: that is an attribute, and the guard that reads it weighs the document against the bundle, never against the census.

production: { mangle: true, mangleDebugGlobal: true }

Also assigns the runtime registry to window.__csszyx (mangleMap, varMangleMap, checksum, encode, decode, encodeVar, decodeVar, decodeGlobalVar, decodeAll) for inspection in devtools. The helpers read the registry internally, so nothing about correctness depends on the global.

Off by default because the global is a named handle any script on the page can bind to — an extension, a host shell, a third-party embed — and a stable surface to bind to is what mangling takes away. It keeps nothing secret: the same map ships in the page as the inert __CSSZYX_MANGLE_MAP__ census and inside your JS bundle, and devtools reads the census on a build you cannot rebuild. Nothing else assigns the global.

interface GlobalVarMangleConfig {
enabled: boolean;
mode?: 'alias';
tokens?: string[];
autoPrefix?: string;
onUnsafeUsage?: 'error';
reserved?: string[];
emitMap?: boolean;
}
OptionDefaultDescription
manglefalseReplace class names with short encoded symbols
mangleVarsfalseOpt into shorter names and bounded hoisting for dynamic CSS variables generated from runtime sz values. Disabled builds keep existing --_sz-* output.
mangleVarHoistMaxDepth5Maximum DOM ancestor distance for component-tier CSS variable hoisting when mangleVars is enabled.
mangleGlobalVarsundefinedOpt-in alias gate for explicit app-owned global CSS custom properties. Aliasing is the only mode and keeps original public variables defined.
minifytrueMinify emitted CSS and JS

mangleVars does not define global theme-token aliases. It only rewrites the dynamic CSS variables csszyx emits for runtime sz values, such as p: pad, and keeps the feature opt-in while the global token tier remains a separate design surface.

mangleGlobalVars enables the global token tier for explicit tokens. It is default-off, accepts only mode: 'alias', and must not remove original CSS custom-property names. autoPrefix remains disabled until csszyx can build the same alias table before source transforms and CSS output validation. Set emitMap: false only when the standalone .csszyx/global-var-map.json tooling asset is not needed; csszyx-manifest.json still includes globalVarAliases when aliases exist.

Controls the build pipeline.

interface BuildConfig {
buildId?: string; // Build identifier (auto-generated if omitted)
emitManifest?: boolean; // Emit csszyx-manifest.json for @csszyx/dynamic
outputDir?: string; // Output directory
cacheDir?: string; // Cache directory
cache?: boolean; // Enable per-file transform cache
astBudgetLimit?: number; // Max AST nodes per file before the transform skips it (warned)
parser?: "rust" | "wasm"; // Engine artifact for JSX/TSX sz transforms
scanCss?: string | string[]; // CSS files with @theme blocks to auto-scan
}
OptionDefaultDescription
buildIdAutoUnique build identifier
emitManifestfalseEmit csszyx-manifest.json. Only @csszyx/dynamic reads it, to skip injecting rules the built CSS already has. See below before enabling.
outputDir'.csszyx'Plugin output directory
cacheDir'.csszyx/cache'Incremental build cache
cachetrueEnable the per-file transform cache. Set to false when debugging parser output or isolating cache-related issues.
astBudgetLimit50000Max AST nodes per file before the transform gives up on it — guards against pathologically large generated files. A file past the cap is left unrewritten, contributes no classes to the safelist, and is warned about by every parser engine (the native rust engine included). The safelist prescan runs with a 10× cap by default so real page files keep their CSS; setting this applies your value to both lanes. Raise (e.g. 100_000) when a warning names a legitimate file, or exclude generated files via top-level exclude.
parser'rust'Source parser for JSX/TSX sz transforms. Default uses the native Rust engine through the matching optional @csszyx/core-* platform package; when that binary is absent and the choice was only inherited, the build degrades to 'wasm' — the same engine compiled to WebAssembly, shipped inside @csszyx/core, identical output. An explicit value always fails loudly instead of degrading. Set 'wasm' to pin the WebAssembly build (useful where native addons cannot load at all). The former 'oxc'/'babel' TypeScript lanes were removed; a config still naming them is ignored like an invalid env value and the build runs on the default.
scanCssundefinedCSS files to scan for @theme blocks. Literal paths and simple globs are supported.
importedStaticSztrueCompile a static sz object a component imports from another module. On by default; set false to leave those imports to the runtime — see below.

Imported Static sz Objects (importedStaticSz)

Section titled “Imported Static sz Objects (importedStaticSz)”

On by default. The prescan records exported static sz objects and every importer compiles them as if the literal were written locally:

card.styles.ts
export const cardSz = { p: 4, rounded: 'lg' };
// Card.tsx
import { cardSz } from './card.styles';
<article sz={cardSz} /> // → className="p-4 rounded-lg"

Turned off, sz={cardSz} falls back to the runtime and — this is the part that costs more than a helper call — contributes no classes. The class text then appears in no output at all, so nothing tells Tailwind to generate it and the element renders naming a rule that does not exist. That is why the default is on: the alternative is a build that reports missing CSS and names this setting as the way out, which is guidance rather than a setting.

It remains a setting for a narrower reason. Resolving across modules means a file’s compiled output is no longer a pure function of its own text, and a project that hits a cross-file resolution problem needs a one-line way back to the file-local behaviour rather than a version downgrade.

Turning it off does not make a mutated style object safe. The compiler reads the object where it is declared, and a property write on a local const is already uncompensated — see the shared-style-object contract. Treat a shared sz object as declared once and never written to, whichever way this setting is set.

What it covers: a direct sz={binding} from a named import, including a renamed one (import { cardSz as card }), a namespace member (import * as S, then sz={S.cardSz}), and a default import when the module writes the literal in the default slot (export default { … }). The value must be a fully static object literal in the module the importer names.

A re-export is followed rather than refused: a barrel (export { cardSz } from './base') is read as a link to the module that declares the value, so importing through one compiles like importing the provider directly. What still keeps the runtime path is export *, which names no export to file a value under, and export default cardSz, which puts an identifier where the literal would be. Reusing Styles has the measured table.

The specifier may be relative (./styles, ../shared/styles, any depth) or an alias (@/styles). Aliases are read from the bundler’s own resolve.alias and from compilerOptions.paths in tsconfig.json — Next.js declares @/* only in the second, so both are consulted. An alias csszyx cannot express as a literal prefix (a RegExp find, or a paths pattern with text after the *) is skipped, and its importers keep the runtime path.

On the Next.js Turbopack lane the option lives on the loader, because the plugin never runs there. Every lane defaults to on, so a project that wants it needs no wiring at all. Turning it OFF has to be done in every place — the loader emits the class and the prebuild is what safelists it, so one without the other ships a class name with no rule behind it:

next.config.mjs
turbopack: csszyxTurbopack({}, { importedStaticSz: false })
Terminal window
# build
csszyx next prebuild --no-imported-static-sz
# dev
csszyx next watch --no-imported-static-sz

All three have to agree — the loader, the prebuild, and the watcher. A mismatch is not silent in a production build: the flag is part of the generation identity, so it fails with “csszyx config hash changed” rather than shipping classes with no CSS. In a dev session it is quieter, and a watcher left without the flag maintains a second identity beside the loader’s. Set it on every csszyx loader rule too, if you declare more than one.

What still falls back — and keeps reporting that it did: export *, package specifiers, absolute specifiers, and any nested position such as sz={[cardSz, …]} or sz={{ ...cardSz }}.

Use top-level include / exclude to control which source files csszyx parses. Filters accept literal paths, simple globs, or RegExp patterns. Excludes run before AST parsing, so large generated files can be skipped without hitting the AST budget.

csszyx({
exclude: ["src/generated/**", /icon-dump\.tsx$/],
});

csszyx hard-ignores /packages/ by default (a published library ships pre-extracted CSS) and pre-scans only the build root. In a monorepo, a design-system you author — whether under /packages/ or a sibling outside the build root — is source you want compiled. Opt it in by path:

csszyx({
compileSources: ["packages/vui", "../libs/ui"],
});

Paths resolve like Vite config paths: relative to the resolved project root (config.root, default the build cwd); absolute paths pass through. Each entry (a) exempts that directory from the ignore so its sz/szv is compiled, and (b) becomes a pre-scan root so its classes are safelisted. Symlinked workspace packages (pnpm) are matched after realpath resolution (relies on Vite’s default resolve.preserveSymlinks: false).

node_modules and .next stay ignored unless a listed path points into them. A /packages/ file using csszyx that is not under any compileSources directory is skipped (no CSS) — csszyx warns at build end and lists those files, naming any that export szv factories, because those also cost every importer its cross-module precompile. A path that does not resolve to a directory is reported in a build warning.

A shared lib outside /packages/ (e.g. libs/ui) that lives inside the build root needs no config — it is compiled and scanned automatically; compileSources is only needed for /packages/ or for sources outside the build root.

Default true. Tailwind v4’s automatic content detection scans every file under its detection base — .md/.mdx/.txt are not ignored — so a doc or fixture holding class-shaped strings can generate phantom or broken url() classes and fail the build. How wide that base is depends on the build: a Vite build roots it at the Vite root, other setups at the workspace root, where sibling packages are scanned too. When your Tailwind entry imports tailwindcss without scoping it (no source(none) / source(...) / @source not), csszyx warns once at build end with the two-line fix. Set false to silence that warning when a broad scan is intentional.

csszyx({
contentScopeCheck: false, // silence the unscoped-monorepo warning
});

See Monorepo & Content Scope for the full fix.

Default false. Set true to silence all csszyx build warnings — the skipped-sz, missing-Tailwind-entry, unscoped-content, unresolvable-spread, and safelist-cap messages. Errors that abort the build (security / crash / build-break guards) still throw — only warnings are muted.

true is blunt, and worth being blunt about: it also hides the messages saying classes never reached the safelist, so the CSS for them is absent from the output. Those are not style advice — they report that the build produced less than you asked for.

csszyx({
quiet: 'nudges', // calmer log, integrity reports kept
});

'nudges' silences the same warnings a production build already suppresses — the usage advice aimed at whoever is writing csszyx code — and keeps every report about missing output. Reach for true only when you have decided the missing output is acceptable.

When set, the plugin parses @theme blocks in your CSS entry point and generates .csszyx/theme.d.ts — a declaration file that augments the CustomTheme interface in @csszyx/compiler. This surfaces custom design tokens (colors, spacing, fonts, radii, shadows) in sz prop IntelliSense without any manual type declarations.

This option scopes types only. szcn’s theme merge groups are discovered from every stylesheet in the project either way, so listing one entry here never costs you merging in another.

vite.config.ts
...csszyx({
build: {
scanCss: 'src/index.css', // path to CSS with @theme blocks
},
})
src/index.css
@import "tailwindcss";
@theme {
--color-brand-500: #6d28d9;
--color-brand-600: #5b21b6;
--spacing-prose: 65ch;
--radius-card: 12px;
}

After the first build, .csszyx/theme.d.ts is generated. Add it to your tsconfig.json so TypeScript picks it up:

{ "include": ["src", ".csszyx/theme.d.ts"] }

Result: { bg: 'brand-500' } and { maxW: '--spacing-prose' } get full autocomplete and type-checking.

scanCss accepts a glob or array: ['src/index.css', 'src/tokens.css']. Theme files are watched in dev mode — hot-reload triggers type regeneration.

The same scan also registers your color, text-size, font-family, and font-weight tokens into szcn’s merge groups, so classes built from them dedupe correctly with zero extra wiring. This matters for semantic token names: with --color-warning and --color-danger scanned, szcn('text-warning', 'text-danger') keeps only the later class, while text-warning + text-sm co-exist (color vs size). Without the scan, szcn cannot tell warning is a color and safely keeps both. (Shade-shaped names like brand-500 classify by their {name}-{shade} shape alone and dedupe even unscanned.) Tokens from hand-written CSS outside @theme can be registered manually with registerSzcnGroups.

When scanCss is unset, the plugin still discovers @theme blocks on its own: it walks the project (and any compileSources packages) for CSS files carrying @theme and feeds their tokens to the szcn groups, so last-wins merging works with zero config. All @theme option keywords (inline, static, reference) are recognized. The auto-scan covers runtime merging only.csszyx/theme.d.ts type generation still requires an explicit scanCss, and setting one turns the auto-scan off in favor of exactly the files you listed.

Default false. csszyx-manifest.json lists every class the build emitted, and @csszyx/dynamic fetches it to answer one question per class: is this already in the built CSS? A yes means dynamic() reuses the existing rule instead of injecting one.

The catch is the asymmetry. The file carries the whole census, while dynamic() only asks about the handful of classes it renders. Measured on a 668-class census (pnpm bench:dynamic-manifest):

Share of the app rendered through dynamic()ManifestInjecting instead
1%2078 B gz162 B gz
10%2078 B gz501 B gz
50%2078 B gz1990 B gz
75%2078 B gz3030 B gz
100%2078 B gz4109 B gz

It only comes out ahead once most of the app is styled at runtime. Numbers from one synthetic census — treat the shape as the lesson, not the constants.

Turning it off is safe in every case: a missing manifest means dynamic() treats nothing as pre-built and injects its own rules, so the rendered styles are identical. What changes is bytes and one-time work.

To find out which side your app is on, measure it:

import { dynamicReport } from "@csszyx/dynamic";
// after the app has exercised the paths that matter
console.log(dynamicReport().summary);
// "Manifest cost 2078 B and spared only 340 B of injected CSS —
// set build.emitManifest to false."

Controls SSR hydration verification.

interface HydrationConfig {
strict: boolean; // Enable strict checks
}
OptionDefaultDescription
stricttrueAbort on any mismatch (non-configurable in production)