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.
Quick Start
Section titled “Quick Start”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 { NextConfig } from 'next';const csszyxWebpack = require('@csszyx/unplugin/webpack').default;
const nextConfig: NextConfig = { webpack(config) { config.plugins.push( csszyxWebpack({ production: { mangle: true }, }), ); return config; },};
export default nextConfig;const csszyxWebpack = require('@csszyx/unplugin/webpack').default;
module.exports = { plugins: [ csszyxWebpack({ production: { mangle: true }, }), ],};TypeScript Types
Section titled “TypeScript Types”import type { PartialCsszyxConfig } from "@csszyx/types";
const csszyxOptions: PartialCsszyxConfig = { exclude: ["src/generated/**"], development: { debug: true }, production: { mangle: true },};Configuration Sections
Section titled “Configuration Sections”development
Section titled “development”Controls development mode behavior.
interface DevelopmentConfig { strictMode: boolean; // Treat warnings as errors debug: boolean; // Enable debug logging}| Option | Default | Description |
|---|---|---|
strictMode | false | Treat all CSSzyx warnings as build errors |
debug | false | Enable 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.
production
Section titled “production”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}What mangling buys
Section titled “What mangling buys”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.
Where the runtime mangle map ships
Section titled “Where the runtime mangle map ships”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.
hydrationCensus
Section titled “hydrationCensus”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.
mangleDebugGlobal
Section titled “mangleDebugGlobal”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;}| Option | Default | Description |
|---|---|---|
mangle | false | Replace class names with short encoded symbols |
mangleVars | false | Opt into shorter names and bounded hoisting for dynamic CSS variables generated from runtime sz values. Disabled builds keep existing --_sz-* output. |
mangleVarHoistMaxDepth | 5 | Maximum DOM ancestor distance for component-tier CSS variable hoisting when mangleVars is enabled. |
mangleGlobalVars | undefined | Opt-in alias gate for explicit app-owned global CSS custom properties. Aliasing is the only mode and keeps original public variables defined. |
minify | true | Minify 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}| Option | Default | Description |
|---|---|---|
buildId | Auto | Unique build identifier |
emitManifest | false | Emit 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 |
cache | true | Enable the per-file transform cache. Set to false when debugging parser output or isolating cache-related issues. |
astBudgetLimit | 50000 | Max 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. |
scanCss | undefined | CSS files to scan for @theme blocks. Literal paths and simple globs are supported. |
importedStaticSz | true | Compile 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:
export const cardSz = { p: 4, rounded: 'lg' };
// Card.tsximport { 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:
turbopack: csszyxTurbopack({}, { importedStaticSz: false })# buildcsszyx next prebuild --no-imported-static-sz# devcsszyx next watch --no-imported-static-szAll 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 }}.
File Filters
Section titled “File Filters”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$/],});Extra Sources (compileSources)
Section titled “Extra Sources (compileSources)”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.
Content Scope Check (contentScopeCheck)
Section titled “Content Scope Check (contentScopeCheck)”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.
Quiet (quiet)
Section titled “Quiet (quiet)”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.
Theme Auto-Scan (scanCss)
Section titled “Theme Auto-Scan (scanCss)”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.
...csszyx({ build: { scanCss: 'src/index.css', // path to CSS with @theme blocks },})@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.
Dynamic manifest (emitManifest)
Section titled “Dynamic manifest (emitManifest)”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() | Manifest | Injecting instead |
|---|---|---|
| 1% | 2078 B gz | 162 B gz |
| 10% | 2078 B gz | 501 B gz |
| 50% | 2078 B gz | 1990 B gz |
| 75% | 2078 B gz | 3030 B gz |
| 100% | 2078 B gz | 4109 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 matterconsole.log(dynamicReport().summary);// "Manifest cost 2078 B and spared only 340 B of injected CSS —// set build.emitManifest to false."hydration
Section titled “hydration”Controls SSR hydration verification.
interface HydrationConfig { strict: boolean; // Enable strict checks}| Option | Default | Description |
|---|---|---|
strict | true | Abort on any mismatch (non-configurable in production) |