The runtime package provides helpers for composing className strings
dynamically. For static sz props, no helpers are needed — the compiler
produces plain strings at build time.
Runtime helpers
szr · szcn · szDecode · splitBox + class toolkit · SSR hydration validator
~0B Runtime Cost
Static sz props — zero overhead. Helpers only ship when you use them.
Tree Shakeable ESM
Import only what you use. Dead code eliminated at build time.
Resolves multiple class strings or SzObjects into a single mangle-aware className.
Falsy values (false, null, undefined) are skipped. szr is the public,
hand-written name; _sz is the identical helper the compiler injects (the _
marks generated code — do not hand-author it). Reach for szr when you build a
className from szv factory output (e.g. a code-split layout that resolves
variants at the leaf). szr concatenates; to merge with last-wins override on a
same-utility conflict use szcn (mangle-aware className merge).
import{
constszr: (...classes: SzInput[])=> string
Back-compat szr — the public, hand-written name for
_sz
. See
coreSz
for the full contract (szr vs szcn, falsy handling).
Creates a variant-based sz object factory with strong TypeScript inference.
TypeScript catches invalid variant values at compile time — no runtime
surprises. All variant objects are plain sz objects, fully compatible
with the sz prop and
@csszyx ― /dynamic's sz() function.
@param ― config - Variant configuration with base, variants, and defaultVariants
@returns ― A factory function that accepts a variant selection and returns an SzObject
Creates a variant-based sz object factory with strong TypeScript inference.
TypeScript catches invalid variant values at compile time — no runtime
surprises. All variant objects are plain sz objects, fully compatible
with the sz prop and
@csszyx ― /dynamic's sz() function.
@param ― config - Variant configuration with base, variants, and defaultVariants
@returns ― A factory function that accepts a variant selection and returns an SzObject
The main @csszyx/runtime entry stays fully standalone: szr({ p: 4 }) works
with no plugin and no extra import, and that guarantee is exactly why importing
szr from it ships the browser transform (~13 KB gz) — the object branch
must be able to lower anything. The build removes that cost automatically
whenever it can PROVE objects never flow:
@csszyx/runtime/core — the concat family (szr, _sz, _sz2, _sz3,
__szvPick, __szvPick1), string-first, no compiler (~0.6 KB gz). When every szr(...)
argument in a file is provably a string or falsy — string/template literals,
false/null/undefined, &&/ternaries whose reachable results are all
safe — the compiler retargets the import here on its own, splitting a mixed
clause like import { szr, szv } so the rest stays put. Anything uncertain
(an identifier, a call, x as string) keeps today’s import.
@csszyx/runtime/merge — the group-merge family (_szcn, _szPart,
_szMerge, ~5 KB of merge tables, no compiler). Injected instead of the
main entry when every dynamic sz array element is provably a string.
@csszyx/runtime/split — the className half of the class toolkit
(classify, has, pick, omit, splitBox, normalizeBase,
stripVariant), without the sz-object adapters. Unlike the entries above,
the plugin never retargets to this one — you import it by hand when a project
reads Tailwind strings and never passes sz objects to the toolkit. Under ESM
the main entry already tree-shakes to within 73 B of it; the saving is on
the require() path, where nothing shakes: 25.2 KB gz against
31.2 KB. It does not drop @csszyx/compiler from the dependency graph:
both halves of the toolkit live in one module, so dist/split.cjs still
requires @csszyx/compiler/browser even though nothing on this entry calls
into it.
@csszyx/runtime/lowering — one bare side-effect import that makes the
slim helpers object-capable. The plugin handles it; outside the plugin
pipeline (unit tests, scripts) add import '@csszyx/runtime/lowering' once
at startup. An object reaching a slim helper without it throws an error that
names this exact line — never silently unstyled markup.
szv factories join in: a file-local (or, in production builds, an IMPORTED)
factory with a fully literal config compiles per key — a static selection
becomes the final class string at build time, a dynamic one becomes a
__szvPick(table, selection) lookup (~40× faster per render than the object
path) — as long as no two co-occurring branches touch the same property, since
object merge keeps one class where concatenation keeps both. A call that
selects exactly ONE dimension by a literal name, F({ direction: dir }),
narrows further to __szvPick1(table, "direction", dir), which skips both the
per-render selection object and the walk over every other dimension (measured
~4× faster again on a five-dimension table). That narrowing needs the table to
carry no defaultVariants, since a default makes the omitted dimensions
contribute classes too. The cross-module half runs in production builds only
and resolves RELATIVE import specifiers; dev keeps the unoptimized behavior so
nothing can go stale under HMR.
All of it is conservative by construction: every uncertain shape keeps the
current code, so the worst case is byte-for-byte today’s output. Mirrors of
core and merge exist as csszyx/core and the umbrella re-exports.
Merges className strings with last-wins override per utility — the merge to
use at the single resolution point of a layered component (typically the leaf
Box), and for merging a part’s own defaults with a consumer override. Falsy
inputs (false, null, undefined, '') are skipped.
npm tailwind-merge cannot do this job here: in a production build csszyx
mangles owned classes (gap-2 → q3, gap-8 → q7), and tailwind-merge
can’t tell q3/q7 are the same utility. szcn decodes each token through the
runtime mangle registry (getMangleRegistry().decode, registered by the build
from inside the bundle) before grouping, so overrides keep working with
mangling on.
functionszcn(...inputs: ClassInput[]): string
Merge className strings with last-wins override per utility, mangle-aware and
memoized (see the memo note above — repeated inputs return in one Map lookup).
Intended for the single resolution point in a layered design-system component
(typically at the leaf Box): combine the component's default classes with the
forwarded override so the override wins on a same-utility collision, while
keeping production mangling intact (unlike npm tailwind-merge).
@param ― inputs - Class strings; falsy inputs (false/null/undefined/'') are skipped.
Merge className strings with last-wins override per utility, mangle-aware and
memoized (see the memo note above — repeated inputs return in one Map lookup).
Intended for the single resolution point in a layered design-system component
(typically at the leaf Box): combine the component's default classes with the
forwarded override so the override wins on a same-utility collision, while
keeping production mangling intact (unlike npm tailwind-merge).
@param ― inputs - Class strings; falsy inputs (false/null/undefined/'') are skipped.
szcn('md:gap-2','gap-8');// → 'md:gap-2 gap-8' variants isolate — both kept
Fail-safe contract: a token whose conflict group can’t be determined
confidently is NEVER merged away — it keys by itself, so the worst case is two
classes co-existing (the pre-merge status quo), never a wrongly-dropped class.
A shorthand appearing later removes the longhands it subsumes; a longhand
appearing later only refines. Covers padding, margin, inset, and border-radius:
szcn('pb-4','p-8');// → 'p-8' p covers pb
szcn('p-4','pb-8');// → 'p-4 pb-8' pb only refines the bottom
Logical sides (ps/pe, ms/me) are subsumed by their p/m shorthands.
For inset and rounded the coverage is physical-only: inset/rounded do
not subsume logical start/end / rounded-s* tokens, which could flip under
RTL — those stay keep-both (still cascade-correct).
Sixteen prefixes span more than one CSS property (text-sm is font-size,
text-red-500 is color). For these, szcn classifies the token value into
a property group: same group → last wins, different group → co-exist,
unclassifiable → keep-both.
Setting a shadow’s size and its color together is the documented Tailwind way
to write one, so those two never share a group — the same reasoning that keeps
border-2 and border-red-500 apart.
Still keep-both by design: directional/axis forms of border-family prefixes
(border-t-2 vs border-2, divide-x-2 vs divide-y-2), CSS-variable values
(text-(--brand) — the type is unknown), and any value csszyx cannot classify.
On a production-mangled build, szcn also encodes its output. A class name
your component resolves at runtime as a plain string — a prop mapped to
'flex-col', a template like `gap-${n}` — never went through the
compiler, so without encoding it would reach the DOM in its original spelling
while the shipped CSS only contains the mangled selector. szcn closes that
gap: every surviving token is looked up in the runtime mangle map and leaves
the merge in mangled form.
The lookup is single-pass and idempotent. Already-mangled tokens, authored
literals, classes kept by production.manglePreserve, and external (non-csszyx)
class names pass through unchanged — token allocation guarantees a mangled
token can never spell a censused class name, so one map lookup is unambiguous.
In dev, or on a build without mangling, the encode step is an identity.
Code that inspects a className for a utility by its original spelling must
decode first — see szDecode.
Custom token names have to be declared before szcn can classify classes built
from them. The build plugin does this automatically from your @theme blocks —
these functions are for hand-written CSS utilities, and for reading back what
is in effect.
Declarations are kept per source, defaulting to 'app'. That is what lets a
rebuild replace everything it scanned without touching what your code
registered by hand — the build uses the source 'build'.
Function
Does
registerSzcnGroups
Adds names, keeping what the source already declared. The default for hand-written CSS.
setSzcnGroups
Replaces the source’s whole declaration. A category left out is cleared.
clearSzcnGroups
Drops one source, or every source when called with no argument.
getSzcnGroups
Reads the names currently in effect — what survived the guard rails, sorted and copied. For tests and diagnostics, not a hot path.
Guard rails — both fall back to keep-both with a one-time dev warning, never a
wrong merge:
A name that shadows a built-in value keyword of an affected prefix is
rejected: a color named cover would make szcn misread bg-cover
(background-size) as a color and merge it wrongly.
A name declared in two conflicting categories (both a color and a text
size — text-huge becomes unclassifiable) is dropped from both, for as long
as both declarations exist. The effective sets are recomputed from every
source on each change, so neither guard rail depends on the order things were
declared in.
Maps a mangled class token back to its original name. On any build shape where
the token is not mangled — dev, mangle: false, an authored literal, an
external class — it returns the input unchanged, so it is always safe to call.
functionszDecode(token: string): string
Reach for it whenever code inspects a className for a utility by its
original spelling. String checks like startsWith('w-') silently stop
matching on a mangled build (the DOM carries q3, not w-full); decoding
first keeps the check correct on every build:
import{ has, szDecode }from'@csszyx/runtime';
szDecode('q3');// 'w-full' on that build; 'q3' where nothing was mangled
has decodes every token through the registry and strips the variant before
matching, so md:hover:w-full and its mangled form both count as a width. A
hand-rolled check has to do both itself: szDecode returns the whole original
token (szDecode('x7') can be 'md:hover:w-full'), and startsWith('w-')
on that is false.
Not a runtime function: the build rewrites every
szs={{…}}
call site to szsc={{ slot: "class string" }}, so the component receives plain
strings on a dedicated prop and forwards them into a child className with no
helper. Declare both faces from one slot union with SzsProps:
CSR recovery is now opted in per-element via the szRecover JSX
attribute ("csr" or "dev-only"); no global flag needed. See
SSR & Hydration → Recovery Tokens.
All variant class combinations are catalogued at build time — the compiler extracts every
possible output and adds them to the Tailwind safelist so CSS is pre-generated.
When a caller passes one flat className to a component that renders nested
elements, the styles often belong on different elements — the margin on the
outer frame, the padding on the inner content. splitBox partitions a className
at the CSS box-model border line into { outer, inner }. Nothing is lost and
every token keeps its variant prefix: each lands in exactly one bucket, except
the timing group (transition-*, duration-*, ease-*, delay-*), which is
declared on both because the state that fires a transition can sit on either
node.
functionsplitBox(className: string, options?: {
outer?: BoxSelector[];// force these onto the outer node
inner?: BoxSelector[];// force these onto the inner node
// optionally qualified by a property (`text:color`)
The qualified form reads a classtoken, soitappliesto `splitBox`, `classify`, `has`, `pick` and `omit` only; thesz-objecttwinskeybyszpropandtaketheunqualifiedforms.
The split follows the box model: outer = border-outward (margin, position,
border, sizing, background, shadow, transform, visibility, the clip the box
applies to itself — overflow-hidden/overflow-clip — and the pointer:
cursor-*, select-*, pointer-events-*); inner = border-inward (padding,
scrolling — overflow-auto/overflow-scroll — display, layout, gap, text,
paint-inside, divide-*). The class-token → box-role map is generated from
the compiler’s property tables, so it never drifts. Every default is
overridable.
The category-aware toolkit exposes csszyx’s class knowledge as primitives.
csszyx owns the truth (a class’s box-role + category); your project owns the
rule (which dependent classes to add, under which conditions) — no rule-DSL,
no hardcoded Tailwind vocabulary.
functionclassify(token: string):{
role: 'outer' | 'inner';
category: string;
property?: string;// only for prefixes that span more than one property
property names the CSS property inside the category for the prefixes that
cover several — classify('text-red-500') reports color and
classify('text-sm') reports size, both in category text. It is the same
classification szcn merges by, so a token name declared in the app’s Tailwind
@theme (see Theme group registry) is read the way a
built-in one is. The field is absent when the prefix means exactly one
property (p-4) and when the value does not confidently name one
(border-t-2) — never null, never empty — and it never changes which node
splitBox routes a token to.
A selector qualified with a property matches tokens that satisfy both halves,
which is how a rule addresses text colours without touching text sizes:
The two steps the toolkit takes before it classifies a token, exported so a
component that needs one of them does not reimplement it.
functionstripVariant(token: string): string
functionnormalizeBase(base: string): string
stripVariant returns the utility after its variant chain, scanning from the
right for the last : at bracket depth zero, so a colon inside a value is not a
variant boundary:
stripVariant('md:hover:w-4')// 'w-4'
stripVariant('[&:hover]:w-full')// 'w-full'
stripVariant('bg-[url(http://a/b)]')// unchanged — no variant
A token is at the base breakpoint exactly when stripping leaves it unchanged —
after decoding, because on a production build a mangled md:w-1/2 is one
opaque token with no colon in it, and stripVariant would call it base:
That is the question a component asks when a responsive width must not count as
a width everywhere.
normalizeBase removes the important marker (leading or trailing !) and a
leading negative sign: normalizeBase('!w-4') and normalizeBase('-mt-4') give
w-4 and mt-4. Give it the output of stripVariant — on a token that still
carries its variant it strips only what sits at the ends, so md:!w-4 comes
back unchanged.
Neither decodes a mangled token — call szDecode first on a production build.
has, pick, omit and splitBox do all three steps themselves.
The sz-object twin of splitBox: partitions an sz object (not a className)
into { outer, inner } sz objects, so a component built with szv stays
sz-native and keeps the compiler’s auto-safelisting. Each key routes to the same
side its emitted class would — splitBoxSz(x) ≡ splitBox(compile(x)).
Arrays are deep-merged (last-write-wins); null / false / undefined and
empty objects yield { outer: {}, inner: {} }. A raw className string has no
sz-object form, so it throws in development.
Removes sz before a component spreads ...rest onto a host element. Compiled
components never carry a leftover sz, but a file that was not compiled
(e.g. a workspace package missing from compileSources) keeps its raw sz,
which leaks to the DOM as sz="[object Object]". stripSzProps drops it and,
in development, warns once when the leaked sz is a raw object.
When production mangling is on, csszyx embeds a mangle map plus a checksum so the
runtime can detect a map that was altered or drifted out of sync.
verifyMangleChecksumAsyncrecomputes the map’s checksum with the Web Crypto
API (crypto.subtle) and compares it to the expected value — no WASM core
required, which makes it usable in edge/serverless runtimes.
functionverifyMangleChecksumAsync(
expectedChecksum: string,
map?: MangleMap, // loaded + schema-validated from the DOM when omitted
):Promise<boolean>// true only when the map is present and its checksum matches