Static — build-time
Literals, enums, szv variants. The compiler lowers sz → Tailwind classes and
safelists them; Tailwind generates the CSS at build. Zero runtime, SSR-safe. The
default — use it for almost everything.
csszyx has two worlds. Almost every question — “why is my class dead?”, “does this run in the browser?”, “will this tree-shake?” — comes down to which world a style lands in. Pick by one test:
Is the value set finite and known at build time? Yes → static (build-time). No → runtime (
dynamic()).
Static — build-time
Literals, enums, szv variants. The compiler lowers sz → Tailwind classes and
safelists them; Tailwind generates the CSS at build. Zero runtime, SSR-safe. The
default — use it for almost everything.
Runtime — dynamic()
Unbounded values (CMS colors, user #hex, arbitrary data). CSS is generated and
injected in the browser via insertRule. The escape hatch for genuinely infinite
value sets — not a component’s base mechanism.
Parsing your JSX source to find and transform sz props happens at build time
through a three-tier engine pipeline:
| Tier | Engine | When |
|---|---|---|
| 1 (default) | Rust (native) | the default parser |
| 2 (fallback) | wasm — the same engine compiled to WebAssembly | when the native binary is unavailable for a platform |
The runtime (browser) never uses any of these — there is no parser at
runtime. The runtime helpers are plain JavaScript; dynamic()’s sz-object
lowering uses @csszyx/compiler/browser, a pure-JS transform(szObject) that
lowers an already-built object to a class string (no source to parse).
| Path | sz → class | → CSS | When |
|---|---|---|---|
| Static | build-time | build-time (Tailwind) | a literal sz / szv — the default |
dynamic(), class already built | runtime | build-time (reuses the built CSS) | a dynamic value whose class was safelisted |
dynamic(), new class | runtime | runtime (insertRule) | a value not in the built CSS (truly dynamic) |
Only the third path generates CSS in the browser, and only for classes that aren’t already in your built stylesheet.
| Helper | What it is | Build-time or runtime |
|---|---|---|
sz={{ … }} (literal) | the prop | build-time — compiler extracts + safelists |
szv | variant factory → sz object | build-time — the compiler extracts the szv({…}) config (see below); the factory call merges objects at runtime, but the CSS is built |
splitBox (string) | partitions a className string | runtime routing only — operates on classes already resolved elsewhere; emits no new CSS |
splitBoxSz (object) | partitions an sz object | runtime routing only — the classes’ CSS comes from their static source, not from this call |
dynamic() | resolves an sz object → className, injects CSS | runtime — the only helper that generates CSS in the browser |
A class gets CSS only if it appears in a statically analyzable position the compiler extracts:
sz={{ … }}, orszv({ … }) config literal.szv extraction is declaration-based: the compiler reads every variant class
from the szv({…}) config wherever it’s declared, regardless of how the returned
factory is later used — directly, through splitBoxSz, through _sz, or not at
all. So indirection does not break safelisting.
Discovery is a cheap text check: the prescan scans any file containing sz=,
sz:, or a szv( declaration. So a standalone variants.ts holding only a
szv({…}) config (no JSX, no sz=) is still scanned and its variants extracted.
Everything above is about where CSS is generated. One narrower question sits underneath it: can this key hold a runtime value at all?
Usually yes. sz={{ p: pad }} compiles to p-(--_sz-p) plus a style custom
property, so the CSS is built and only the value arrives at runtime — path one
above, not path three.
A minority of keys have no Tailwind utility that reads a CSS variable, so that
trick has nothing to compile to. text-(--v) is a colour in Tailwind v4, so
a runtime textAlign produced a colour rule holding "center"; display-(--v)
matches no utility, so the class was safelist noise. csszyx drops the class and
the variable and reports it, rather than shipping either.
<div sz={{ p: 4, textAlign }} /> // → className="p-4", textAlign reportedThe way back onto the static path is to give the compiler the vocabulary — a
ternary between literals for two options, szv for a real set. See
Keys that need a build-time value
for the complete list and the three shapes that compile.
csszyx safelists by content presence, exactly like Tailwind itself — a class
is emitted if it appears in scanned content, not by import-graph reachability.
Installing csszyx does not change your app’s tree-shaking model: it mirrors
Tailwind. Co-locating szv in its component file makes the component the natural
shaking boundary (component never imported → file never transformed → its
variants never safelisted).
sz on an HTML tag vs on a component (read this)The compiler rewrites sz= into className= on whatever element it’s written
on, and extracts/safelists the classes — but where that className ends up
differs:
<div sz={{ p: 4, bg: 'blue-500' }} /> // → <div className="p-4 bg-blue-500" /><Box sz={{ p: 4, bg: 'blue-500' }} /> // → <Box className="p-4 bg-blue-500" />div, span, …) the className lands on the DOM element →
the styles apply. ✅<Box>) the className becomes a prop passed to the
component. The component receives className — not sz, and not an
object — and must forward it onto a real HTML tag, or the styling is lost.// ✅ A stylable component forwards className to an HTML tag.function Box({ className = '', children }) { return <div className={className}>{children}</div>;}<Box sz={{ p: 4, bg: 'blue-500' }} /> // → <Box className="p-4 bg-blue-500" /> → onto the divTwo rules keep a component library fast (build-time) and overridable.
className; never dynamic() for finite stylingA component is styled from the app via the className it receives (the compiled
output of the app’s sz=). Forward it to a DOM tag. Do not resolve styling
inside the component with dynamic() — that turns every consumer into runtime CSS
injection; one dynamic() in a foundational component infects the whole app.
Reserve dynamic() for unbounded/untrusted values the app explicitly passes.
splitBoxA component that renders nested elements receives ONE className (the app’s
override). Split it at the box-model border with splitBox
and combine each part with the component’s own base classes:
function ScrollArea({ className = '', children }) { const { outer, inner } = splitBox(className); // route the app's override return ( <div className={`rounded-lg overflow-hidden ${outer}`}> {/* frame + outer override */} <div className={`p-4 ${inner}`}>{children}</div> {/* viewport + inner override */} </div> );}<ScrollArea sz={{ m: 4, p: 2 }} /> // → className="m-4 p-2" → m-4 routed outer, p-2 innersplitBox takes a string for exactly this reason — by the time the app’s sz
reaches the component it is already a className string. (splitBoxSz, which takes
an object, is for routing an sz object the component builds internally — e.g.
from a szv — onto nested sz= props before they compile.) Don’t mix sz= and
className= on the same element; keep a component’s base and the routed override
in one channel (plain className literals, as above).
dynamic() vs staticA dynamic()-injected rule is unlayered; static sz / Tailwind utilities
live in @layer utilities. Per CSS Cascade Level 5, unlayered beats all layered
styles at equal specificity — so a dynamic() rule wins over a static sz
override regardless of source order. By design they don’t collide: dynamic()
only injects classes that are not already built. If you mix them on one element,
the dynamic rule takes precedence. Prefer all-static within a component.