Skip to content

szv() — Variant Authoring

szv() is the csszyx equivalent of CVA (class-variance-authority). It returns a factory function that produces sz objects — keeping DX consistent with the sz prop throughout.

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' },
destructive: { bg: 'destructive', text: 'destructive-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 },
icon: { h: 10, w: 10 },
},
},
defaultVariants: { variant: 'default', size: 'md' },
});
// Usage — returns an sz object, feeds directly into sz prop
<button sz={buttonSz({ variant: 'outline', size: 'sm' })} />
// defaultVariants apply when no selection given
<button sz={buttonSz()} />

TypeScript inference — no annotations needed

Section titled “TypeScript inference — no annotations needed”

szv() infers all variant keys and valid values from the config object. TypeScript catches invalid values at the call site without any manual type annotations:

buttonSz({ variant: 'outline' }) // ✅ valid
buttonSz({ variant: 'invalid' }) // ❌ TypeScript error: '"invalid"' not assignable
buttonSz({ size: 'xl' }) // ❌ TypeScript error: '"xl"' not assignable
buttonSz({}) // ✅ defaultVariants apply
buttonSz() // ✅ same as {}

szv() output is a plain sz object — compose it with conditional styles using the array syntax:

<button sz={[
buttonSz({ variant: props.variant, size: props.size }),
isLoading && { opacity: 50, cursor: 'wait' },
isDisabled && { opacity: 50, cursor: 'not-allowed' },
]} />

The compiler handles static items at build time; conditionals use _szMerge at runtime.

Base hover styles are preserved when a variant adds its own hover:

const cardSz = szv({
base: { rounded: 'lg', hover: { shadow: 'md' } },
variants: {
color: {
blue: { bg: 'blue-50', hover: { bg: 'blue-100' } },
red: { bg: 'red-50', hover: { bg: 'red-100' } },
},
},
});
cardSz({ color: 'blue' })
// → { rounded: 'lg', bg: 'blue-50', hover: { shadow: 'md', bg: 'blue-100' } }
// ^^^^^^^^^^^ ↑ base kept

null / undefined falls back to defaultVariants

Section titled “null / undefined falls back to defaultVariants”

Passing null or undefined for a variant key is treated as “not specified” — the defaultVariant applies instead of clearing the style:

<button sz={buttonSz({ variant: props.variant ?? null })} />
// If props.variant is null/undefined → defaultVariants.variant ('default') applies

szv() works identically in runtime-injection mode:

import { useSz } from 'csszyx/dynamic/react';
const { sz } = useSz();
<button className={sz(buttonSz({ variant: 'outline' }))} />
const pill = szv({
variants: {
size: {
sm: { px: 2, py: 1, text: 'xs' },
lg: { px: 4, py: 2, text: 'base' },
},
},
defaultVariants: { size: 'sm' },
});
<span sz={pill()} /> // size: 'sm' (default)
<span sz={pill({ size: 'lg' })} />

The compiler reads every variant class from the szv({…}) config literal at its declaration — regardless of how the returned factory is later used (directly in sz=, through splitBoxSz, or not at all). So routing the output through a helper never loses the CSS.

The prescan discovers a file by a cheap text check: it scans any file containing sz=, sz:, or a szv( declaration. So a standalone file holding only a szv({…}) config (no JSX, no sz=) is still scanned and its variants extracted.

Extraction is lenient per key, identically in all three parser engines. A value the compiler cannot read statically (a function call, a template string, an imported constant…) skips only that one key — sibling keys and every other variant still reach the safelist. A finite conditional like p: dense ? 2 : 4 contributes both branches (the runtime picks one, so both classes must exist). null/undefined mean “key unset”. Same-file const references and const object spreads are followed (mx: GUTTER, { ...shared, mx: 0 }); a reassigned let never is. Co-locating each szv in the component file that uses it is still the recommended layout — it makes the component the unit of tree-shaking. In a monorepo, opt a workspace package in with compileSources so its szv is scanned too.

Imported factories are precompiled in production builds only

Section titled “Imported factories are precompiled in production builds only”

szr(importedFactory({…})) collapses to a build-time string using a registry the prescan fills, and that registry is switched off in a dev server and in vite build --watch: both reuse one prescan, so an edited factory would keep serving importers its old table. The runtime path produces identical classes, so the only difference is bundle size.

Because the registry is off, those call sites cannot be resolved in those modes. csszyx does not report them there — the “result is unknown at build time” advice would be asking you to rewrite code that compiles perfectly in a production build. A fallback naming anything else still reports normally.

In development, szv validates the config shape and the selection and warns once on a problem — a missing variants, a non-object variant value, or a selection that names an unknown variant or value. It degrades safely (a broken config falls back to base / {} rather than throwing per render). TypeScript catches these at author time; the runtime guard covers configs built from JSON / as any / a plain-JS caller. Production is a no-op.

szv returns a plain sz object — it does not run through dynamic(). Its CSS is built either way: every variant class is read out of the config and safelisted wherever the factory is declared. Only wrap it in dynamic() for genuinely runtime values; see Build-Time vs Runtime.

Where the two call positions differ is the class selection, not the CSS:

Call positionSelectionNotes
className={szr(btnSz({…}))}build timethe factory call is replaced with the picked string
sz={btnSz({…})}runtime _sz()same classes, same CSS; one helper call per render

Both are correct and both are supported. Reach for szr on className when a component renders often enough for the difference to matter. When a factory does not precompile, both positions now report it the same way — naming the factory and the config position that disqualified it, not “convert to szv()”.

Precompiling stores one class string per branch, and a selection joins the strings it picked. That is only the object you wrote when no two branches that can appear together set the same property. If base sets color and a variant sets color, joining them yields text-main text-sub, and which one wins is decided by the order of those rules in the stylesheet — not by the order in the attribute. The runtime deep-merges the objects first, so it yields text-sub, full stop. Rather than emit a class string that renders differently from the object, csszyx declines to precompile the config and the factory keeps its (correct) runtime path.

Nothing is missing from your CSS when this happens: every branch’s classes are still safelisted. What you lose is the build-time collapse.

The report names the position to change:

szv factory `card()` did not precompile — its config disqualified at `base.color`

Read the position by which half it names:

PositionMeaningFix
base.<key>a variant sets the same property as basedrop the key from base and give each variant value its own, or accept the runtime path
variants.<dim>.<value>.<key>two different dimensions set the same propertykeep the property in one dimension
variants.<dim>.<value>.<key> on a key csszyx cannot canonicalizethe key is neither a property nor a variant, and its value is not an objectuse a canonical key

A base conflict always names the base key, even when several variants shadow it: base applies to every selection, so it is the one edit that resolves all of them. Values of the same dimension never conflict with each other — they are mutually exclusive, so only one is ever picked.

Variants your project defines are fine here

Section titled “Variants your project defines are fine here”

A branch may nest any variant, including ones no compiler table can know:

const card = szv({
base: { px: 2, tablet: { px: 4 } }, // ✅ your @theme breakpoint
variants: {
dir: { left: { p: 2, 'data-[active]': { p: 4 } } }, // ✅ attribute variant
},
});

Both precompile and both emit the prefixed class (tablet:px-4, data-[active]:p-4). A custom breakpoint comes from your @theme and an attribute variant is written inline, so neither can appear in a table csszyx ships — but a variant composes onto the property path rather than replacing it, so overlap analysis still works through it and px and tablet.px stay separate.

A flag utility carries its meaning in the boolean, because Tailwind has no value form for it:

const label = szv({
base: { srOnly: true, tabularNums: true }, // ✅ canonical spelling
variants: { pad: { sm: { p: 2 }, lg: { p: 8 } } },
});

Each flag lowers to one fixed class, and no flag combines with another key into a composite the way text and leading do, so its own name places it against every other branch.

What still disqualifies is an unrecognised key holding a plain value ({ nonsenseKey: 'x' }): that lowers to nonsenseKey-x, a name csszyx cannot place against any other branch’s. That includes a value on a flag key{ srOnly: 'weird' } lowers to sr-only-weird, so the admission above is for the boolean, not for the key.