Skip to content

Plugin Config

CSSzyx is configured in two places. Plugin options — what the build does — are passed to the plugin in your bundler config. Diagnostic levels — how loudly each finding is reported — live in csszyx.config at the project root, the one file every bundler plugin, csszyx check and the Next commands read.

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(),
],
});

csszyx.config.ts (or .mts, .js, .mjs) at the project root sets the level of each kind of finding. Plugin options in a bundler config reach only that bundler, so csszyx check could never see them; this file is read by every bundler plugin, by csszyx check, and by csszyx next prebuild and csszyx next watch, which hand it to the Turbopack loader.

csszyx.config.ts
import { defineConfig } from 'csszyx';
export default defineConfig({
diagnostics: {
preset: 'recommended', // or 'atomic'
rules: { 'class-precedence': 'error', 'merge-covered-key': 'off' },
allow: { classes: ['my-later-class'], tokens: ['brand'] },
overrides: [{ files: ['src/legacy/**'], rules: { 'class-precedence': 'off' } }],
},
});
FieldMeaning
presetThe starting levels. recommended (the default) makes every finding an error, a value csszyx drops such as slash-opacity-string or a gradient layer on mask (moved-value) included, except notes about a choice the author made or output that is still complete — class-precedence, duplicate-sz, merge-covered-key, merge-covered-class, fallback-nudge, mangle-vars-hoist-skipped and color-slash-opacity are info, and merge-classifier-unavailable is warn. atomic raises the notes to warn, for an app reviewing every site where csszyx changed what was written.
rulesA level per id: off, info, warn or error. An id is a pass of csszyx check (sz-diagnostic, dead-class, broken-opacity, sibling-keyword, theme-collision, merge-covered-key, merge-covered-class) or a kind (unknown-key, class-precedence, prefix-disagreement and the rest of the kinds check --json reports). A pass id sets every kind under it; a kind id beats its pass.
allowNames to leave out of every report: classes that produce no CSS on purpose (as check --allow), theme tokens that shadow a utility on purpose (as check --allow-token). A filter on the report only — the class is still emitted.
overridesLevels for some files, applied after rules and in order. files takes globs relative to the project root: ./src/** reads as src/**, . as the whole project, a backslash as /, and a plain path such as src/legacy covers that path and everything under it. An absolute glob (/src, C:/app) or one with a .. segment can match no file, so it is reported like an unknown id.

Precedence is preset, then rules, then each matching overrides entry. defineConfig returns its argument unchanged; it is there for the types, and every field is optional.

LevelDev server / development buildProduction buildcsszyx check
offNot printed.Not printed.Not reported.
infoPrinted (the merge report as one summary line).Counted in one closing line, not listed; the merge report prints nothing.Reported; the run passes.
warnPrinted with its file and line.Printed.Reported; the run passes.
errorPrinted with its file and line.Printed.Reported; the run exits 1.

A build never fails on a level: an error is printed, and the build goes on. Every bundler lane reads the levels — Vite, webpack (Next.js --webpack included), Rollup, esbuild, the jest transform and the Turbopack loader — and prints each site once per process while its file is unchanged, at most ten lines per id per start or build (on a dev server, per burst of compiles); Warnings lists what each lane prints. quiet: true still mutes all of it. csszyx check --fail-on info or --fail-on warn fails the run on quieter findings too; --rule and --ignore-rule still only choose which findings are reported.

An id the file does not know, a level or preset it cannot use, a misspelt diagnostics key, a key of diagnostics (rules, preset, allow, overrides) written at the top level, an override glob that can never match, a file with no default export (export const config = …) and a file that does not load are printed as a warning at build and fail csszyx check, naming the id or key they most likely meant. A plugin option written in this file is not read from it, and says so. One file that does not load is only reported and ignored, without failing check: the csszyx.config.js that csszyx init 0.17 and earlier wrote into a JavaScript project, recognised by its shape (import type { CsszyxConfig }, const config: CsszyxConfig = { … }, export default config, in either quote style, with any spacing, comments or semicolons) — TypeScript syntax that no JavaScript loader accepts, holding plugin options nothing read. Replace it with export default defineConfig({ diagnostics: { … } }), importing defineConfig from csszyx, or delete it. Passing diagnostics to the plugin itself is refused the same way as any unknown option: it would apply to one bundler and drift from the gate.

A build reads the file when it starts. A Vite dev server reads it again each time it, or a module it imports by a relative path, is saved, created or deleted: it prints the file’s problems again, compiles every module and reloads the page, so every finding is listed at its new level. A watch build — webpack, rollup -w, vite build --watch, an esbuild context — reads it on the rebuild the save starts, and compiles again every module that printed a finding, so each one is printed at its new level. Under vite build --watch a config created during the session is read at the next rebuild something else starts: Vite 8’s Rolldown watcher does not follow a file that does not exist yet. Under Turbopack the levels reach the loader through .csszyx/diagnostic-policy.json, which csszyx next prebuild writes, and csszyx next watch writes again on every save of the config or a module it imports; a module that printed a finding runs through the loader again when that file, the config or such a module changes.

A session reads an edited module the config imports as it is now, not as it first loaded it. Node keeps an imported module for the life of the process, so a reload after such an edit loads the config in a short-lived Node process (about 40–60 ms); a reload after an edit to the config alone stays in process. That process starts with the flags this one was started with that decide how modules load — --import, --loader, --require, --conditions and the --experimental-* switches — so a config that loads through a loader such as tsx registered that way loads there too; a debugger, test-runner, watch or --eval flag stays behind. The classes the merge removed are listed again when the edit gives merge-covered-key or merge-covered-class another level.

Deleting a module the config imports is an edit too: the reload loads the config again and prints why it could not, and the recommended levels apply until the module is back. A relative import of a file that does not exist yet is followed as well — creating the file is read like a save — under the name written, or, for a specifier written without an extension, under each name the lookup would try (./levels, ./levels.ts, … ./levels/index.mjs). An editor that saves by replacing the file is read once per save. Only an import written as a string is followed: a computed one, such as import(`./levels/${name}.mjs`) or import('./' + name), cannot be read without running the config, so an edit to the module it loads is read only when the session restarts. Write such an import with a string specifier.

Node loads the file itself — Node 22.18 or 24.11 and later strip TypeScript types natively, so nothing else is installed. Outside a "type": "module" package — "type": "commonjs", or no "type" at all — a .ts or .js file written with export default is still loaded, and Node prints no advice to change the package type: csszyx imports it as an ES module from a copy written beside it, and removes the copy. In such a package csszyx init names the file csszyx.config.mts (or .mjs), which Node always reads as an ES module.

import type { PartialCsszyxConfig } from "@csszyx/types";
const csszyxOptions: PartialCsszyxConfig = {
exclude: ["src/generated/**"],
development: { debug: true },
production: { mangle: true },
};

Controls development mode behavior.

interface DevelopmentConfig {
debug: boolean; // Enable debug logging
}
OptionDefaultDescription
debugfalseEnable verbose debug logging

strictMode was removed: nothing read it, so it never turned a warning into an error. A build never fails on a diagnostic level; gate on csszyx check instead.

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 verifyMangleChecksumAsync read.

It follows what the build renames: a build that renames no class and no custom property has nothing to map, so no census is written whatever this option says.

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: loadMangleMapFromDOM answers “nothing to read”, and verifyMangleChecksumAsync recomputes from the map the bundle installs instead, so the check still runs. 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
mergeCoveredClasses?: boolean; // Drop a class a later one on the element covers (default true)
}
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 and for the sz values of Vue and Svelte templates. 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.
mergeCoveredClassestrueDrop a class a later one on the same element fully covers: a later sz key over an earlier one ({ pb: 2, p: 4 } → p-4) and a static sz over a static class name (className="pb-2" sz={{ p: 4 }} → p-4). Only Tailwind’s own utilities take part. Set false when the stylesheet is read wrong for a project; static output then keeps every class, as before 0.18. On Turbopack, pass it to csszyxTurbopack.

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 an object literal in the module the importer names, and it qualifies exactly when the same literal would compile written in the importing file: it may read a const its own module declares ({ p: SPACE }), spread one ({ ...base, p: 4 }) or read a member of one ({ p: SIZES.md }). A value that module itself imports is not followed, so an object built from another module’s export keeps the runtime path.

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.

csszyx writes your Tailwind prefix before every class it emits, so sz={{ p: 4 }} becomes tw:p-4 under @import "tailwindcss" prefix(tw). To learn the prefix it compiles the stylesheets in your project that reach Tailwind. It finds them by walking the project root, which also finds test fixtures, copied stylesheets and ones the app no longer loads. When two of them set different prefixes, the build stops and lists each one with its prefix.

List the stylesheets your app loads, and nothing else is read for the prefix:

csszyx({
tailwindStylesheet: "src/index.css",
});

Accepts a path or an array of paths, relative to the project root. A listed path that does not exist stops the build. Without the option, the walk skips node_modules, dist, build, target, coverage, storybook-static and every dot-directory.

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 every custom design token Tailwind serves on an sz key (colors, spacing, fonts, animations, easings, container sizes and the rest) in sz prop IntelliSense without any manual type declarations.

This option scopes types only. What szcn merges comes from the CSS your Tailwind compiles, and the theme groups classify reads are discovered from every stylesheet in the project, so listing one entry here never costs you either 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 the theme group registry, so classify('text-warning') reports color for a --color-warning token with zero extra wiring. (Shade-shaped names like brand-500 classify by their {name}-{shade} shape alone, even unscanned.) Tokens from hand-written CSS outside @theme can be registered manually with registerSzcnGroups.

szcn does not depend on this scan. It merges on the CSS your Tailwind compiles, so szcn('text-warning', 'text-danger') keeps only the later class and text-warning + text-sm co-exist with no scan at all — see How it decides.

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 theme group registry, so classify names their property with zero config. All @theme option keywords (inline, static, reference) are recognized. The auto-scan covers the registry only — .csszyx/theme.d.ts type generation still requires an explicit scanCss. Setting one does not turn the auto-scan off: the registry keeps coming from every stylesheet in the project, and the files you listed add the types on top.

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)