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.
Basic usage
Section titled “Basic usage”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' }) // ✅ validbuttonSz({ variant: 'invalid' }) // ❌ TypeScript error: '"invalid"' not assignablebuttonSz({ size: 'xl' }) // ❌ TypeScript error: '"xl"' not assignablebuttonSz({}) // ✅ defaultVariants applybuttonSz() // ✅ same as {}Compose with sz array syntax
Section titled “Compose with sz array syntax”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.
Nested variants are deep merged
Section titled “Nested variants are deep merged”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 keptnull / 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') appliesWith @csszyx/dynamic
Section titled “With @csszyx/dynamic”szv() works identically in runtime-injection mode:
import { useSz } from 'csszyx/dynamic/react';
const { sz } = useSz();<button className={sz(buttonSz({ variant: 'outline' }))} />No base required
Section titled “No base required”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' })} />Build-time extraction & co-location
Section titled “Build-time extraction & co-location”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.
Config validation (development)
Section titled “Config validation (development)”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.
Dynamic & specificity
Section titled “Dynamic & specificity”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 position | Selection | Notes |
|---|---|---|
className={szr(btnSz({…}))} | build time | the 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()”.
When a factory does not precompile
Section titled “When a factory does not precompile”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:
| Position | Meaning | Fix |
|---|---|---|
base.<key> | a variant sets the same property as base | drop 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 property | keep the property in one dimension |
variants.<dim>.<value>.<key> on a key csszyx cannot canonicalize | the key is neither a property nor a variant, and its value is not an object | use 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.
Boolean flags are fine here too
Section titled “Boolean flags are fine here too”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.