High-performance Tailwind CSS variant recipes with split cv and scv APIs.
cv: single-output variant recipes that return one class stringscv: multi-slot variant recipes that return a slot-to-class mapalias: remap inherited slot names without changing variant propsderive: compute variant props from incoming props at call timedefaults: preset recipe default variants without rebuilding the recipeextendBase: compute dynamic base classes or slots from resolved variant propscn: flattenClassValueinputs with css-variants style semanticsmerge: merge class parts withtailwind-mergeVariantProps: extract the public variant prop type from a recipe- runtime overrides use rest arguments instead of
class/classNameprops
pnpm add @soybeanjs/cvaimport { alias, cn, cv, derive, defaults, merge, scv } from '@soybeanjs/cva';
import type { VariantProps } from '@soybeanjs/cva';This package keeps the two common recipe shapes separate:
- use
cvwhen the result is one final class string - use
scvwhen the result is a record of named slots
That split keeps the runtime small, keeps the types direct, and avoids overloading a single API with two different output models.
Use cn when you want css-variants style class flattening for plain ClassValue inputs.
import { cn } from '@soybeanjs/cva';
cn('inline-flex', ['items-center', ['justify-center']], { 'font-medium': true, hidden: false });
// "inline-flex items-center justify-center font-medium"cn only flattens values. It does not resolve Tailwind conflicts.
Use merge when you already have an ordered list of class parts and want Tailwind conflict resolution.
import { merge } from '@soybeanjs/cva';
merge(['px-2 text-sm', 'px-4', 'mt-2']);
// "text-sm px-4 mt-2"merge is a thin wrapper around tailwind-merge and is useful when classes are already collected as string parts.
Use cv for a single class string.
import { cv } from '@soybeanjs/cva';
const button = cv({
base: 'inline-flex items-center rounded-md font-medium',
defaultVariants: {
size: 'md',
tone: 'primary'
},
variants: {
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-5 text-base'
},
tone: {
primary: 'bg-blue-600 text-white',
secondary: 'bg-slate-100 text-slate-900'
},
disabled: {
false: 'opacity-100',
true: 'pointer-events-none opacity-50'
}
},
compoundVariants: [
{
class: 'shadow-sm',
size: 'lg',
tone: 'primary'
}
]
});
button();
// "inline-flex items-center rounded-md font-medium h-10 px-4 text-sm bg-blue-600 text-white opacity-100"
button({ size: 'lg', tone: 'secondary' });
// "inline-flex items-center rounded-md font-medium h-12 px-5 text-base bg-slate-100 text-slate-900 opacity-100"Pass extra classes through rest arguments:
button({ size: 'lg' }, 'mt-4', ['shadow-lg', 'ring-1']);Overrides are applied after all base, variant, and compound classes.
cv can extend other cv recipes directly.
import { cv } from '@soybeanjs/cva';
const surface = cv({
variants: {
size: {
sm: 'text-sm',
lg: 'text-lg'
},
tone: {
primary: 'bg-blue-600 text-white',
secondary: 'bg-slate-100 text-slate-900'
}
}
});
const button = cv({
extend: [surface],
variants: {
intent: {
solid: 'shadow-sm'
}
}
});Inherited variant props are part of the child recipe type, and child defaultVariants / compoundVariants can also target inherited variants.
Use extendBase when the base classes depend on the fully resolved variant props.
import { cv } from '@soybeanjs/cva';
const button = cv({
base: 'rounded-md',
defaultVariants: {
size: 'sm',
tone: 'primary'
},
extendBase: props => [props.tone === 'primary' ? 'ring-1' : 'ring-0', props.size === 'lg' ? 'px-4' : 'px-2'],
variants: {
size: {
sm: 'text-sm',
lg: 'text-lg'
},
tone: {
primary: 'bg-blue-500',
secondary: 'bg-slate-200'
}
}
});
button();
// "ring-1 px-2 rounded-md text-sm bg-blue-500"
button({ size: 'lg', tone: 'secondary' });
// "ring-0 px-4 rounded-md text-lg bg-slate-200"extendBase runs after inherited extend recipes have resolved, and before the local base field is appended.
Use scv when each slot needs its own final class string.
import { scv } from '@soybeanjs/cva';
const card = scv({
slots: {
root: 'rounded-lg border p-4',
header: 'mb-2 font-semibold',
body: 'text-sm'
},
defaultVariants: {
tone: 'neutral'
},
variants: {
tone: {
neutral: {
root: 'border-slate-200 bg-white',
body: 'text-slate-600'
},
brand: {
root: 'border-blue-200 bg-blue-50',
body: 'text-blue-900'
}
},
compact: {
false: {},
true: {
root: 'p-3',
header: 'mb-1',
body: 'text-xs'
}
}
},
compoundVariants: [
{
class: {
root: 'shadow-sm'
},
compact: false,
tone: 'brand'
}
]
});
card({ tone: 'brand' });
// {
// root: 'rounded-lg border p-4 border-blue-200 bg-blue-50 shadow-sm',
// header: 'mb-2 font-semibold',
// body: 'text-sm text-blue-900'
// }scv overrides are also rest arguments, but each argument is a slot map.
card({ tone: 'brand' }, { root: ['mt-4', 'shadow-lg'] }, { body: ['leading-6'] });Each slot is merged independently.
Use extendBase when slot base classes depend on the resolved variant props, or when a slot should be filled by another recipe at call time.
import { cv, derive, scv } from '@soybeanjs/cva';
const button = cv({
base: 'inline-flex',
defaultVariants: {
fitContent: false,
size: 'md'
},
variants: {
fitContent: {
false: '',
true: 'w-fit h-fit'
},
size: {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-lg'
}
}
});
const iconButton = derive(button, props => ({
fitContent: true,
size: props.size === 'lg' ? 'sm' : props.size
}));
const card = scv({
extendBase: () => ({
close: iconButton()
}),
slots: {
close: '',
root: 'rounded-lg'
}
});
card({ size: 'lg' }).close;
// "inline-flex w-fit h-fit text-xs"Inside extendBase, calling another recipe without explicitly passing props reuses the current resolved props. That keeps nested derive and defaults wrappers composable inside extendBase.
scv can extend:
- another
scvrecipe - a slot-mapped
cvrecipe, such as{ root: someCvRecipe }
import { cv, scv } from '@soybeanjs/cva';
const surface = cv({
variants: {
tone: {
neutral: 'bg-white text-slate-900',
brand: 'bg-blue-600 text-white'
}
}
});
const panel = scv({
extend: [{ root: surface }],
slots: {
root: 'rounded-xl p-4',
title: 'font-semibold'
},
variants: {
tone: {
neutral: {},
brand: {}
},
size: {
sm: {
root: 'p-3',
title: 'text-sm'
},
lg: {
root: 'p-6',
title: 'text-lg'
}
}
}
});Direct cv extension is still not allowed in scv:
// not supported
scv({
extend: [surface]
});Map the cv recipe to a slot instead:
scv({
extend: [{ root: surface }]
});Use these helpers when you want to keep recipe metadata intact while changing how variants resolve.
derive computes the next variant selection from the incoming props at call time.
import { cv, derive } from '@soybeanjs/cva';
const button = cv({
defaultVariants: {
size: 'md'
},
variants: {
fitContent: {
false: '',
true: 'w-fit h-fit'
},
size: {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-lg'
}
}
});
const compactButton = derive(button, props => ({
fitContent: true,
size: props.size === 'lg' ? 'sm' : props.size
}));
compactButton();
// incoming props are derived before class resolution
compactButton({ size: 'lg' });
// resolves as if size were 'sm'Use this when the next variants depend on the current call's props.
When a derived recipe is invoked inside extendBase, the outer recipe's current resolved props are used if you do not pass props explicitly.
defaults presets a recipe's defaultVariants while keeping explicit call-time props higher priority.
import { cv, defaults } from '@soybeanjs/cva';
const button = cv({
defaultVariants: {
fitContent: false,
size: 'md'
},
variants: {
fitContent: {
false: '',
true: 'w-fit h-fit'
},
size: {
sm: 'text-xs',
md: 'text-sm'
}
}
});
const iconButton = defaults(button, {
fitContent: true,
size: 'sm'
});
iconButton();
// resolves with fitContent=true and size='sm' as defaults
iconButton({ size: 'md' });
// explicit props still override the new defaultsUse this when you want a recipe variant preset, not dynamic remapping.
Like derive, a defaulted recipe called inside extendBase also inherits the outer recipe's current resolved props when no explicit props are provided.
Use alias when you want to inherit an scv recipe but expose different slot names in the child recipe.
import { alias, scv } from '@soybeanjs/cva';
const card = scv({
slots: {
root: 'rounded-md',
body: 'p-4'
},
variants: {
tone: {
primary: {
root: 'bg-slate-900',
body: 'text-white'
}
}
}
});
const sectionCard = scv({
extend: [alias(card, { root: 'header' })],
slots: {
header: 'font-semibold'
},
variants: {
tone: {
primary: {
header: 'uppercase'
}
}
}
});
sectionCard({ tone: 'primary' });
// {
// body: 'p-4 text-white',
// header: 'rounded-md bg-slate-900 font-semibold uppercase'
// }Aliases also apply to merge input. If a parent slot was renamed from root to header, runtime overrides should target header.
Extract the public variant props directly from a recipe.
import { cv, scv } from '@soybeanjs/cva';
import type { VariantProps } from '@soybeanjs/cva';
const button = cv({
variants: {
size: {
sm: 'text-sm',
lg: 'text-lg'
}
}
});
type ButtonProps = VariantProps<typeof button>;
// { size?: 'sm' | 'lg' }
const card = scv({
extend: [{ root: button }],
variants: {
tone: {
primary: {
root: 'bg-blue-500'
}
}
}
});
type CardProps = VariantProps<typeof card>;
// { size?: 'sm' | 'lg'; tone?: 'primary' }Inherited variant props from extend are included in the extracted type.
roothas no built-in meaning. It is just a conventional slot name.- boolean variants are declared with
'true'and'false'keys and exposed asbooleanin props. - compound variant conditions can use either a single value or an array of values.
extendBasereceives resolved props, which already include inherited and localdefaultVariantsplus the current call's explicit props.- unknown props are ignored at runtime.
tailwind-mergeonly runs when runtime override arguments are provided. If you do not pass overrides, the recipe returns the prejoined output directly.
pnpm test
pnpm typecheck
pnpm buildBenchmark commands are documented in benchmark/README.md.