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) | oxc (pure JS) | when the native binary is unavailable for a platform |
| 3 (last resort) | babel | a final compatibility safety net |
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.
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 not already built), but if you mix them on one element the
dynamic rule takes precedence. Prefer all-static within a component.