Component libraries written with utility CSS (like Tailwind) end up with a button that needs different classes for each intent and each size. class-variance-authority (cva) is the popular helper that turns those choices into a clean className string. You'll implement a simplified version: classVarianceAuthority(config) takes a config object and returns a function that maps a set of props to a single space-joined string of class names.
type VariantConfig = {
base?: string; // classes always applied
variants?: Record<string, Record<string, string>>; // variantName -> value -> classes
defaultVariants?: Record<string, string>; // variantName -> value used when prop is absent
};
// returns a function from selected props to a className string
function classVarianceAuthority(
config: VariantConfig,
): (props?: Record<string, string>) => string;
For each variant key, the chosen value is props[key] when the caller supplied it, otherwise defaultVariants[key]. Look that value up in variants[key] to get its class string.
const button = classVarianceAuthority({
base: 'btn',
variants: {
intent: { primary: 'bg-blue-500', secondary: 'bg-gray-500' },
size: { sm: 'text-sm', lg: 'text-lg' },
},
defaultVariants: { intent: 'primary', size: 'sm' },
});
button({ intent: 'secondary', size: 'lg' }); // 'btn bg-gray-500 text-lg'
button({ size: 'lg' }); // 'btn bg-blue-500 text-lg' (intent default)
button(); // 'btn bg-blue-500 text-sm' (all defaults)
// An unknown variant value contributes nothing — and does NOT fall back to the default.
button({ intent: 'danger' }); // 'btn text-sm' (intent adds nothing; size uses its default)
defaultVariants; a present-but-unknown value does not. Passing intent: 'danger' (not in the table) adds no class and never reaches the default — those are two different cases.base is empty or every variant resolves to nothing.variants object. Emit classes in the order the keys are declared in variants, with base first.tailwind-merge style conflict resolution, boolean variants, or array/object class values — every class entry is a plain string. Those are out of scope (see the solution's Going further).