Skip to content

Runtime Helpers

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.

import { szr, szcn } from '@csszyx/runtime';

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 {
const szr: (...classes: SzInput[]) => string

Back-compat szr — the public, hand-written name for

_sz

. See

coreSz

for the full contract (szr vs szcn, falsy handling).

szr
,
function szv<V extends VariantSchema>(config: SzvConfig<V>): (selection?: VariantSelection<V>) => 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

@example

import { szv } from 'csszyx';
const buttonSz = szv({
base: { display: 'inline-flex', items: 'center', rounded: 'md', weight: 'medium' },
variants: {
variant: {
default: { bg: 'primary', text: 'primary-foreground' },
outline: { border: true, borderColor: 'blue-500', bg: 'transparent' },
ghost: { hover: { bg: 'accent' } },
},
size: {
sm: { h: 9, px: 3, text: 'sm' },
md: { h: 10, px: 4 },
lg: { h: 11, px: 8 },
},
},
defaultVariants: { variant: 'default', size: 'md' },
});
// Usage — consistent with sz prop, TypeScript catches invalid values
<button sz={buttonSz({ variant: 'outline', size: 'sm' })} />
// Compose with sz array syntax
<button sz={[
buttonSz({ variant: props.variant, size: props.size }),
isLoading && { opacity: 50, cursor: 'wait' },
]} />
// With

@csszyx ―

/dynamic for fully runtime-resolved styling const { sz } = useSz(); <button className={sz(buttonSz({ variant: props.variant }))} />

szv
} from '@csszyx/runtime';
const
const cardSz: (selection?: VariantSelection<{
pad: {
lg: {
p: number;
};
};
}> | undefined) => SzObject
cardSz
=
szv<{
pad: {
lg: {
p: number;
};
};
}>(config: SzvConfig<{
pad: {
lg: {
p: number;
};
};
}>): (selection?: VariantSelection<{
pad: {
lg: {
p: number;
};
};
}> | undefined) => 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

@example

import { szv } from 'csszyx';
const buttonSz = szv({
base: { display: 'inline-flex', items: 'center', rounded: 'md', weight: 'medium' },
variants: {
variant: {
default: { bg: 'primary', text: 'primary-foreground' },
outline: { border: true, borderColor: 'blue-500', bg: 'transparent' },
ghost: { hover: { bg: 'accent' } },
},
size: {
sm: { h: 9, px: 3, text: 'sm' },
md: { h: 10, px: 4 },
lg: { h: 11, px: 8 },
},
},
defaultVariants: { variant: 'default', size: 'md' },
});
// Usage — consistent with sz prop, TypeScript catches invalid values
<button sz={buttonSz({ variant: 'outline', size: 'sm' })} />
// Compose with sz array syntax
<button sz={[
buttonSz({ variant: props.variant, size: props.size }),
isLoading && { opacity: 50, cursor: 'wait' },
]} />
// With

@csszyx ―

/dynamic for fully runtime-resolved styling const { sz } = useSz(); <button className={sz(buttonSz({ variant: props.variant }))} />

szv
({
SzvConfig<{ pad: { lg: { p: number; }; }; }>.variants?: {
pad: {
lg: {
p: number;
};
};
} | undefined
variants
: {
pad: {
lg: {
p: number;
};
}
pad
: {
lg: {
p: number;
}
lg
: {
p: number
p
: 8 } } } });
const
const cls: string
cls
=
function szr(...classes: SzInput[]): string

Back-compat szr — the public, hand-written name for

_sz

. See

coreSz

for the full contract (szr vs szcn, falsy handling).

szr
(
const cardSz: (selection?: VariantSelection<{
pad: {
lg: {
p: number;
};
};
}> | undefined) => SzObject
cardSz
({
pad?: "lg" | null | undefined
pad
: 'lg' }), 'bg-blue-500', false, null, 'text-white');

Usage:

// Basic concatenation
<
any
div
className: string
className
={
function szr(...classes: SzInput[]): string

Back-compat szr — the public, hand-written name for

_sz

. See

coreSz

for the full contract (szr vs szcn, falsy handling).

szr
('p-4', 'bg-blue-500', 'text-white')} />
// Falsy values are skipped automatically
const
const isActive: true
isActive
= true;
const
const hasError: false
hasError
= false;
<
any
div
className: string
className
={
function szr(...classes: SzInput[]): string

Back-compat szr — the public, hand-written name for

_sz

. See

coreSz

for the full contract (szr vs szcn, falsy handling).

szr
('base',
const isActive: true
isActive
&& 'active',
const hasError: false
hasError
&& 'error')} />

Bundle-Slim Entries and Build Optimizations

Section titled “Bundle-Slim Entries and Build Optimizations”

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.

szcn(...classes) — Mangle-Aware Override Merge

Section titled “szcn(...classes) — Mangle-Aware Override Merge”

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.

function szcn(...inputs: (string | false | null | undefined)[]): string

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.

function szcn(...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.

@returns ― The merged className string.

@example szcn('gap-2 p-4', 'gap-8') // → 'p-4 gap-8' (gap-8 overrides gap-2)

szcn
('gap-2 p-4', 'gap-8'); // → 'p-4 gap-8' gap-8 overrides gap-2
function szcn(...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.

@returns ― The merged className string.

@example szcn('gap-2 p-4', 'gap-8') // → 'p-4 gap-8' (gap-8 overrides gap-2)

szcn
('md:gap-2', 'gap-8'); // → 'md:gap-2 gap-8' variants isolate — both kept

szcn does not guess from a class name. The build compiles every class your project uses through your own Tailwind, records which CSS properties each one sets and under which selector or at-rule, and hands szcn that table. Two rules follow from it:

  • A later class removes an earlier one when it sets every property the earlier one sets, in the same context — p-8 after pb-4, text-sm after text-base, size-8 after h-4.
  • Anything else co-exists: a later longhand only refines (p-4 pb-8), a different variant is a different context (md:gap-2 gap-8), and !important is its own context (p-4 p-4! keeps both; p-4! p-8! → p-8!).

Because the table comes from your compiled CSS, custom @theme tokens merge the same way built-in ones do, with nothing to register. Only Tailwind’s own utilities merge. A class from @utility, a plugin or plain CSS is never removed and never removes another, and that includes a @utility that reuses a Tailwind name such as container, and the utilities of a package your stylesheet imports, such as tw-animate-css. Neither is a built-in class that a rule of your own CSS selects on (.card.shadow-md { … }): such a class is often a hook for a script or for another rule.

csszyx reads every stylesheet the build walks for these rules, including one tailwindStylesheet leaves out, and an attribute selector such as [class~="shadow-md"] counts. It also reads the <style> blocks of your Vue, Svelte and Astro components, HTML pages and JSX, and every arbitrary variant in your markup or your sz (group-[.shadow-md]:p-2). A selector in CSS-in-JS or in a script (querySelector('.shadow-md')) is not read; for a class matched there, pass it last to szcn, or leave out the class that covers it.

Fail-safe contract: a class the table has no entry for is NEVER merged away — only an exact repeat of it is dropped. The worst case is two classes co-existing (what plain string concatenation gives you), never a class deleted by mistake. That includes a class Tailwind generates no CSS for — one built only at runtime (`gap-${n}`), or one Tailwind’s scan does not reach — and every class on a lane that has no table — see Where the table comes from.

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
szcn('ml-2', 'mx-4'); // → 'mx-4'
szcn('top-2', 'inset-4'); // → 'inset-4'
szcn('rounded-tl-sm', 'rounded-lg'); // → 'rounded-lg'

Logical sides are resolved to the physical side they land on, and a class covers another only when it does so in both ltr and rtl. So a shorthand that spans both sides subsumes the logical longhand (ps-4 px-4 → px-4, start-2 inset-4 → inset-4, rounded-s-lg rounded-lg → rounded-lg), while a logical side and one physical side never subsume each other (ps-4 pl-4 keeps both: they are the same side in ltr and opposite sides in rtl). Vertical writing modes are not modelled; under writing-mode: vertical-* a logical/physical pair that merged may not be the same side.

Some prefixes span more than one CSS property (text-sm is font-size, text-red-500 is color). The compiled CSS tells them apart: same properties → last wins, different properties → co-exist.

// text: size / color / align / wrap / overflow
szcn('text-base', 'text-sm'); // → 'text-sm'
szcn('text-red-500', 'text-sm'); // → 'text-red-500 text-sm'
szcn('text-sm/6', 'text-lg/7'); // → 'text-lg/7' modifiers stay in-group
// font: family vs weight
szcn('font-semibold', 'font-normal'); // → 'font-normal'
szcn('font-sans', 'font-bold'); // → 'font-sans font-bold'
// bg: color / size / position / repeat / attachment / clip / image
szcn('bg-red-500', 'bg-blue-500'); // → 'bg-blue-500'
szcn('bg-red-500', 'bg-cover'); // → 'bg-red-500 bg-cover'
// border / divide / ring / outline: width / color / style
szcn('border-2', 'border-4'); // → 'border-4'
// flex: shorthand / direction / wrap
szcn('flex-row', 'flex-col'); // → 'flex-col'
szcn('flex', 'flex-1'); // → 'flex flex-1' bare flex is display
// shadow / drop-shadow / inset-shadow: size vs shadow color
szcn('shadow-sm', 'shadow-lg'); // → 'shadow-lg'
szcn('shadow-lg', 'shadow-red-500'); // → 'shadow-lg shadow-red-500'
// decoration: thickness / style / color
szcn('decoration-2', 'decoration-red-500'); // → 'decoration-2 decoration-red-500'
// stroke: width vs color
szcn('stroke-2', 'stroke-red-500'); // → 'stroke-2 stroke-red-500'
// from / via / to: gradient stop position vs stop color
szcn('from-10%', 'from-red-500'); // → 'from-10% from-red-500'

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.

Arbitrary and CSS-variable values follow what Tailwind compiles them to: text-[14px] is a font size and loses to a later text-sm; text-(--brand) compiles to color and co-exists with it. Axis forms stay apart (divide-x-2 divide-y-2), and a one-side width is covered by the all-sides one (border-t-2 border-2 → border-2).

The table is settled once per build, from every class the project’s Tailwind generates CSS for: the class names its own scan finds in your sources — the files each Tailwind entry reaches, @source rules included — plus what sz compiles to. A class written in a map of variants, a constant or a design-system package is therefore in it, as long as Tailwind styles it. A class that shares its CSS properties with no other is left out, since it merges the same either way.

Inside a monorepo, a Tailwind entry with no source(...) scans its whole package, linked packages under node_modules included, and the build already warns about it. The table skips that scan rather than pay for it a second time, so scope the entry — @import "tailwindcss" source("./src"), or source(none) with @source — for classes outside className, sz and szcn(...) literals to merge.

How the table reaches the browser depends on the lane:

LaneWhere the table comes from
Vite, Rollupbundled with the app. On the Vite dev server, source edits refresh the table before normal HMR without reloading the page. A stylesheet edit that changes the table reloads the page; edits that leave the table unchanged keep normal HMR.
webpack, Next.js (webpack)bundled with the app, settled again on every compilation
Next.js (Turbopack).csszyx/merge-registration.mjs, written by csszyx next prebuild and kept current by csszyx next watch; the modules that call szcn import it
jestthe same file (its .cjs twin), as the last build or csszyx next prebuild left it — run one before the suite. See Testing.
esbuild, Rspack, Rsbuild, Rolldown, Farm, Bunnone — szcn only drops exact repeats

A project with more than one Tailwind entry (several files that @import "tailwindcss") merges a pair only when every entry agrees on what both classes do. A token declared in one entry alone therefore never merges; declare shared tokens in one file that every entry imports.

Production encode of runtime-resolved classes

Section titled “Production encode of runtime-resolved classes”

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.

The registry names which custom tokens are colors, text sizes, font families and font weights, so classify can report a property for classes built from them. The build plugin fills it from your @theme blocks — these functions are for hand-written CSS utilities, and for reading back what is in effect.

szcn does not read the registry: since 0.18.0 it merges on the compiled CSS (see How it decides), which already knows every token.

interface SzcnThemeGroups {
colors?: readonly string[]; // 'brand' → text-brand, bg-brand, border-brand, …
textSizes?: readonly string[]; // 'huge' → text-huge
fontFamilies?: readonly string[]; // 'display' → font-display
fontWeights?: readonly string[]; // 'chunky' → font-chunky
}
function registerSzcnGroups(groups: SzcnThemeGroups, source?: string): void
function setSzcnGroups(groups: SzcnThemeGroups, source?: string): void
function clearSzcnGroups(source?: string): void
function getSzcnGroups(): Record<'colors' | 'textSizes' | 'fontFamilies' | 'fontWeights', string[]>

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'.

FunctionDoes
registerSzcnGroupsAdds names, keeping what the source already declared. The default for hand-written CSS.
setSzcnGroupsReplaces the source’s whole declaration. A category left out is cleared.
clearSzcnGroupsDrops one source, or every source when called with no argument.
getSzcnGroupsReads the names currently in effect — what survived the guard rails, sorted and copied. For tests and diagnostics, not a hot path.
import { registerSzcnGroups } from '@csszyx/runtime';
// once, at app startup — not per-render
registerSzcnGroups({ colors: ['brand', 'tag-blue-bg'], fontFamilies: ['display'] });

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 classify misread bg-cover (background-size) as a color.
  • 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.

function szDecode(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
const hasWidthClass = (className: string) => has(className, 'w');

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:

import { szcn } from '@csszyx/runtime';
import type { SzsProps } from '@csszyx/types';
function Card({ szsc }: SzsProps<'title' | 'body'>) {
// Slot default + consumer override — szcn resolves conflicts last-wins.
return <h3 className={szcn('font-medium text-base', szsc?.title)} />;
}

Merges multiple SzObjects. Later objects override earlier ones for the same key.

function _szMerge(...objects: SzObject[]): string

Usage:

const baseStyles = { p: 4, bg: 'gray-100', rounded: 'md' };
const activeStyles = { bg: 'blue-500', color: 'white' };
<div className={_szMerge(baseStyles, isActive ? activeStyles : {})} />

Converts a color value to the correct CSS variable format for use in style props or CSS custom properties.

import { __szColorVar } from 'csszyx/lite';
function __szColorVar(value: string): string
InputOutput
'blue-500'var(--color-blue-500)
'#ff0000''#ff0000' (passthrough)
'--my-var'var(--my-var)
'white'var(--color-white)

Usage:

// Dynamic color for style prop (not sz prop)
<div style={{
'--my-accent': __szColorVar(accentColor),
}} />
// Combined with CSS custom property
<svg style={{ fill: __szColorVar('blue-500') }} />

Initializes the CSSzyx runtime. Call once at app startup.

interface RuntimeConfig {
development?: boolean; // Enable dev mode features
strictHydration?: boolean; // Treat hydration warnings as errors
debug?: boolean; // Enable console debug logging
}
function initRuntime(config?: Partial<RuntimeConfig>): void

Usage:

// In your app entry point
import { initRuntime } from '@csszyx/runtime';
initRuntime({
development: process.env.NODE_ENV === 'development',
strictHydration: true,
debug: process.env.NODE_ENV === 'development',
});

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.

Returns the current runtime configuration (read-only copy).

function getRuntimeConfig(): Required<RuntimeConfig>

Returns true if initRuntime() has been called.

function isRuntimeInitialized(): boolean

Creates a type-safe variant factory that returns sz objects. CVA-equivalent for csszyx.

function szv<V extends VariantSchema>(
config: SzvConfig<V>
): (selection?: VariantSelection<V>) => SzObject
import { szv } from '@csszyx/runtime';
// or:
import { szv } from 'csszyx';
const buttonSz = szv({
base: { display: 'inline-flex', items: 'center', rounded: 'md' },
variants: {
variant: {
default: { bg: 'primary', text: 'primary-foreground' },
outline: { border: true, borderColor: 'blue-500' },
},
size: {
sm: { h: 9, px: 3 },
md: { h: 10, px: 4 },
},
},
defaultVariants: { variant: 'default', size: 'md' },
});
<button sz={buttonSz({ variant: 'outline', size: 'sm' })} />

TypeScript infers valid keys/values from the config literal — no manual annotations needed. See the szv() guide for full docs.

Numeric variant keys are fully supported — useful for index-based variants:

const itemSz = szv({
base: { rounded: 'sm', shrink: 0 },
variants: {
idx: {
0: { opacity: 50 },
1: { opacity: 70 },
2: { opacity: 90 },
},
color: {
normal: { bg: '#2dd597' },
reverse: { bg: '#a78bfa' },
},
},
defaultVariants: { idx: 0, color: 'normal' },
});
<div sz={itemSz({ idx: 1, color: 'reverse' })} />
// → "rounded-sm shrink-0 opacity-70 bg-[#a78bfa]"

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.

Pass an array to the sz prop to compose multiple sz objects with conditional items:

// Static items → extracted at build time (zero runtime)
// Conditional items → _szMerge at runtime
<div sz={[
{ display: 'flex', items: 'center', p: 4 },
isActive && { bg: 'blue-500' },
isDisabled && { opacity: 50, cursor: 'not-allowed' },
]} />
// Compose szv() output with extra overrides
<button sz={[
buttonSz({ variant: props.variant }),
isLoading && { opacity: 50, cursor: 'wait' },
]} />

The compiler pre-computes all static objects at build time; only the conditional merge paths use _szMerge at runtime.

splitBox(className, options?) — Nested Element Routing

Section titled “splitBox(className, options?) — Nested Element Routing”

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.

function splitBox(className: string, options?: {
outer?: BoxSelector[]; // force these onto the outer node
inner?: BoxSelector[]; // force these onto the inner node
fallback?: 'outer' | 'inner'; // unrecognized token → default 'outer'
}): { outer: string; inner: string }
// box-role | category | class-prefix | category+value pair,
// optionally qualified by a property (`text:color`)
The qualified form reads a class token, so it applies to `splitBox`, `classify`, `has`, `pick` and `omit` only; the sz-object twins key by sz prop and take the unqualified forms.
type BoxSelector = string | Readonly<Record<string, string>>

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.

import { splitBox } from '@csszyx/runtime';
const { outer, inner } = splitBox('m-4 px-2 md:flex');
// outer: "m-4" inner: "px-2 md:flex"
<Frame className={outer}>
<Content className={inner} />
</Frame>

classify / has / pick / omit — Class Toolkit

Section titled “classify / has / pick / omit — Class Toolkit”

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.

function classify(token: string): {
role: 'outer' | 'inner';
category: string;
property?: string; // only for prefixes that span more than one property
} | undefined
function has(classes: string, selector: BoxSelector): boolean
function pick(classes: string, selector: BoxSelector): string
function omit(classes: string, selector: BoxSelector): string

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. 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:

pick('text-red-500 text-sm font-bold', 'text:color'); // 'text-red-500'
pick('font-bold font-sans', 'font:weight'); // 'font-bold'
has('bg-cover', 'outer:size'); // true
import { splitBox, has, _szMerge } from '@csszyx/runtime';
// A frame that clips needs its content to scroll, and a flex child needs
// `min-h-0` before it will shrink below its own content.
const { outer, inner } = splitBox(className);
const dep = has(outer, { overflow: 'hidden' }) ? 'flex-1 min-h-0 overflow-y-auto' : '';
<Frame className={`${outer} flex flex-col min-h-0`}>
<Scroll className={_szMerge(inner, dep)} />
</Frame>

stripVariant(token) / normalizeBase(base) — Token Parsing

Section titled “stripVariant(token) / normalizeBase(base) — Token Parsing”

The two steps the toolkit takes before it classifies a token, exported so a component that needs one of them does not reimplement it.

function stripVariant(token: string): string
function normalizeBase(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:

import { has, szDecode, stripVariant } from '@csszyx/runtime';
const isBase = (token: string) => {
const original = szDecode(token);
return stripVariant(original) === original;
};
const baseTokens = className.split(/\s+/).filter(isBase).join(' ');
const hasBaseWidth = has(baseTokens, 'w');

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.

splitBoxSz(sz, options?) — sz-Object Routing

Section titled “splitBoxSz(sz, options?) — sz-Object Routing”

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)).

function splitBoxSz(sz: SzInput, options?: {
outer?: BoxSelector[];
inner?: BoxSelector[];
fallback?: 'outer' | 'inner'; // unrecognized key → default 'outer'
}): { outer: SzObject; inner: SzObject }
import { splitBoxSz } from '@csszyx/runtime';
splitBoxSz({ m: 4, px: 2 });
// → { outer: { m: 4 }, inner: { px: 2 } }
// Variants route by their inner property; arrays flatten; inner wins ties.
splitBoxSz({ gap: 2, hover: { px: 1 }, md: { m: 4 } });
// → { outer: { md: { m: 4 } }, inner: { gap: 2, hover: { px: 1 } } }
// Item utilities are outer by default; force them back onto the content:
splitBoxSz({ grow: 2, self: 'center' }, { inner: ['grow', 'self'] });
// → { outer: {}, inner: { grow: 2, self: 'center' } }

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.

The sz-object forms of the class toolkit — same generated truth, keyed by sz prop instead of class token.

function classifySzKey(key: string): { role: 'outer' | 'inner'; category: string } | undefined
function hasSz(sz: SzInput, selector: BoxSelector): boolean
function pickSz(sz: SzInput, selector: BoxSelector): SzObject
function omitSz(sz: SzInput, selector: BoxSelector): SzObject

stripSzProps(props) — Safe Prop Forwarding

Section titled “stripSzProps(props) — Safe Prop Forwarding”

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.

import { stripSzProps } from '@csszyx/runtime';
function Box({ sz, ...rest }: BoxProps) {
return <div {...stripSzProps(rest)} />;
}

verifyMangleChecksumAsync(expected, map?) — Integrity Check

Section titled “verifyMangleChecksumAsync(expected, map?) — Integrity Check”

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

function verifyMangleChecksumAsync(
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
import { verifyMangleChecksumAsync } from '@csszyx/runtime';
const ok = await verifyMangleChecksumAsync(expectedChecksum);
if (!ok) {
// map missing, corrupted, or changed without updating the checksum
}

For edge/serverless environments that can’t afford the full runtime, the @csszyx/runtime/lite export provides _sz without hydration guards:

import { _sz } from '@csszyx/runtime/lite';

Lite exports: _sz, _sz2, _sz3, _szMerge, __szColorVar. No hydration, no checksums, no recovery.