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.

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(). Used as sz={szv(…)} it is fully build-time. Only wrap it in dynamic() for genuinely runtime values; see Build-Time vs Runtime.