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).You'll build a small factory: classVarianceAuthority(config) reads a base string and a table of variants once, and hands back a function that turns a set of props into a single className string every time it's called.
You're styling a button with utility classes. Every button shares some base classes (btn), but the exact look depends on choices: a primary intent paints it blue, a lg size makes the text larger. You want one helper that you configure once — "here are my variants and here are the defaults" — and then call with just the choices for this particular button. It returns the right classes glued together: 'btn bg-blue-500 text-lg'. When a choice is missing, it fills in the default; when a choice doesn't match anything in the table, it quietly contributes nothing.
The shape is a closure — a function that remembers the config it was built with — returning an inner function that runs the lookup each call.
Three steps run on every call. Start from base. Then walk each variant key, and for that key pick a value: the prop the caller passed, or the default if they didn't. Look the value up in the variant's table to get its class string. Collect every class that exists into a list, then join the list with single spaces.
The subtle part is "pick a value." It is not "use the prop if it's truthy." It's "use the prop if the caller supplied the key at all — otherwise use the default." That distinction is the whole question, and we'll come back to it.
Attempt 1 — string concatenation with || defaults. The obvious first pass builds the string by hand and uses || to fall back to defaults:
function classVarianceAuthority(config) {
return function (props) {
let result = config.base;
for (const key in config.variants) {
const value = props[key] || config.defaultVariants[key]; // fall back to default
result += ' ' + config.variants[key][value]; // append the class
}
return result;
};
}
This breaks in three ways at once. Call button({ intent: 'danger' }) where danger isn't in the table: config.variants.intent['danger'] is undefined, and result += ' ' + undefined literally appends the string ' undefined'. Call button() with no argument and props[key] throws, because you can't read a property off undefined. And even on the happy path, a variant whose value resolves to nothing still appended a stray ' ', so you get doubled spaces. The output is dirty and sometimes crashes.
Attempt 2 — fix the join, but keep || for defaults. Push real classes into an array and join them, which kills the spacing bugs:
function classVarianceAuthority(config) {
return function (props = {}) {
const classes = [config.base];
for (const key in config.variants) {
const value = props[key] || config.defaultVariants[key]; // still using ||
const cls = config.variants[key][value];
if (cls) classes.push(cls);
}
return classes.join(' ');
};
}
The spacing is clean now — only real classes enter the array. But one bug survives, and it's the interesting one. props[key] || config.defaultVariants[key] falls back to the default whenever props[key] is falsy — and a missing prop and an explicitly-passed intent: undefined are both falsy. Worse, the spec says a present-but-unknown value like 'danger' should add nothing, but 'danger' is truthy so it happens to work here — until the caller passes intent: undefined or intent: '', where || silently swaps in the default even though the caller did supply the key. We've conflated "was a value passed?" with "is the value truthy?"
function classVarianceAuthority(config) {
const { base = '', variants = {}, defaultVariants = {} } = config;
// Return a function that remembers `config` and runs the lookup per call.
return function (props = {}) {
const classes = [];
if (base) classes.push(base); // skip an empty base so it can't add a stray space
for (const variantName of Object.keys(variants)) {
// Presence, not truthiness: use the prop only if the key was supplied,
// otherwise fall back to the default. An explicit `undefined` still counts
// as "supplied" and therefore does NOT trigger the default.
const chosen =
variantName in props ? props[variantName] : defaultVariants[variantName];
// Look the chosen value up in this variant's table. Unknown value → undefined.
const className = variants[variantName][chosen];
// Only push real strings. A missing match contributes nothing.
if (className) classes.push(className);
}
return classes.join(' ');
};
}
module.exports = { classVarianceAuthority };
Two shifts turn Attempt 2 into a correct solution. First, variantName in props replaces props[key] || default — in asks "did the caller supply this key?", which is exactly the rule the spec wants, and it keeps a present-but-unknown value distinct from an absent one. Second, pushing only truthy className values into the array, then join(' '), means the only thing that can ever sit between two classes is a single space; empty base, unknown values, and unset variants all simply don't enter the array.
Take this config and the call button({ intent: 'primary', size: 'lg' }), where the config also declares a tone variant with no default:
const button = classVarianceAuthority({
base: 'btn',
variants: {
intent: { primary: 'bg-blue-500' },
size: { sm: 'text-sm', lg: 'text-lg' },
tone: { soft: 'opacity-80' },
},
defaultVariants: { intent: 'primary' },
});
classes = []
base is 'btn' (truthy) → classes = ['btn']
key 'intent':
'intent' in props? yes → chosen = 'primary'
variants.intent['primary'] → 'bg-blue-500' (truthy) → push
→ classes = ['btn', 'bg-blue-500']
key 'size':
'size' in props? yes → chosen = 'lg'
variants.size['lg'] → 'text-lg' (truthy) → push
→ classes = ['btn', 'bg-blue-500', 'text-lg']
key 'tone':
'tone' in props? no → chosen = defaultVariants.tone = undefined
variants.tone[undefined] → undefined (falsy) → skip
return classes.join(' ') → 'btn bg-blue-500 text-lg'
tone had neither a prop nor a default, so chosen was undefined, the lookup missed, and nothing was added — no crash, no stray space.
The same call shows all three resolution cases a single variant can hit — prop present and known, prop absent (default), and prop present but unknown:
|| or ?? instead of in. props[key] ?? default looks correct but treats an explicitly-passed undefined (or '' for ||) as "absent" and swaps in the default. The spec wants the default only when the key is genuinely missing. Use key in props to test presence, then read the prop. The diagram below contrasts the two.in, chosen becomes the unknown value (say 'danger'), and variants[key]['danger'] is undefined. The if (className) guard drops it. If you instead wrote chosen = (variants[key][value] ?? variants[key][default]), an unknown value would wrongly inherit the default's class. Don't re-introduce the fallback at the lookup step.+= or pushing a value before checking it lets undefined or an empty string into the output. Push only truthy classes into an array and call join(' ') once — that's the only construction that guarantees no doubled, leading, or trailing space.base adding a stray space. If base is '' and you start the array with [base], the join produces a leading space: ' text-lg'. Guard with if (base) classes.push(base) so an empty base never enters the array.button() passes props = undefined, and undefined[key] throws. Default the parameter (props = {}) so a no-argument call resolves every variant to its default.variants/defaultVariants always exist. A config like { base: 'btn' } has no variants key. Destructure with defaults (variants = {}, defaultVariants = {}) so iterating and looking up are safe on a base-only config.compoundVariants array — extra classes applied only when a combination of variant values is active (e.g. intent: 'primary' AND size: 'lg' together add a shadow). After resolving the base variants, you'd iterate the compound rules, keep each one whose every condition matches the chosen values, and append its classes. It layers cleanly on top of this solution because it runs after the per-key resolution.tailwind-merge. Two variants can emit conflicting utilities — text-sm and text-lg both set font size, and the last one should win. The real cva pairs with tailwind-merge to dedupe conflicting Tailwind classes so the final string has no contradictions. Here we keep every class verbatim; wiring in a merge step would post-process the joined output.true/false (a boolean prop like disabled) and lets each class entry be an array of strings rather than one string. Supporting both means coercing the prop to a string key (String(value)) before the lookup and flattening array values before pushing — small extensions to the resolution and collection steps.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
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).You'll build a small factory: classVarianceAuthority(config) reads a base string and a table of variants once, and hands back a function that turns a set of props into a single className string every time it's called.
You're styling a button with utility classes. Every button shares some base classes (btn), but the exact look depends on choices: a primary intent paints it blue, a lg size makes the text larger. You want one helper that you configure once — "here are my variants and here are the defaults" — and then call with just the choices for this particular button. It returns the right classes glued together: 'btn bg-blue-500 text-lg'. When a choice is missing, it fills in the default; when a choice doesn't match anything in the table, it quietly contributes nothing.
The shape is a closure — a function that remembers the config it was built with — returning an inner function that runs the lookup each call.
Three steps run on every call. Start from base. Then walk each variant key, and for that key pick a value: the prop the caller passed, or the default if they didn't. Look the value up in the variant's table to get its class string. Collect every class that exists into a list, then join the list with single spaces.
The subtle part is "pick a value." It is not "use the prop if it's truthy." It's "use the prop if the caller supplied the key at all — otherwise use the default." That distinction is the whole question, and we'll come back to it.
Attempt 1 — string concatenation with || defaults. The obvious first pass builds the string by hand and uses || to fall back to defaults:
function classVarianceAuthority(config) {
return function (props) {
let result = config.base;
for (const key in config.variants) {
const value = props[key] || config.defaultVariants[key]; // fall back to default
result += ' ' + config.variants[key][value]; // append the class
}
return result;
};
}
This breaks in three ways at once. Call button({ intent: 'danger' }) where danger isn't in the table: config.variants.intent['danger'] is undefined, and result += ' ' + undefined literally appends the string ' undefined'. Call button() with no argument and props[key] throws, because you can't read a property off undefined. And even on the happy path, a variant whose value resolves to nothing still appended a stray ' ', so you get doubled spaces. The output is dirty and sometimes crashes.
Attempt 2 — fix the join, but keep || for defaults. Push real classes into an array and join them, which kills the spacing bugs:
function classVarianceAuthority(config) {
return function (props = {}) {
const classes = [config.base];
for (const key in config.variants) {
const value = props[key] || config.defaultVariants[key]; // still using ||
const cls = config.variants[key][value];
if (cls) classes.push(cls);
}
return classes.join(' ');
};
}
The spacing is clean now — only real classes enter the array. But one bug survives, and it's the interesting one. props[key] || config.defaultVariants[key] falls back to the default whenever props[key] is falsy — and a missing prop and an explicitly-passed intent: undefined are both falsy. Worse, the spec says a present-but-unknown value like 'danger' should add nothing, but 'danger' is truthy so it happens to work here — until the caller passes intent: undefined or intent: '', where || silently swaps in the default even though the caller did supply the key. We've conflated "was a value passed?" with "is the value truthy?"
function classVarianceAuthority(config) {
const { base = '', variants = {}, defaultVariants = {} } = config;
// Return a function that remembers `config` and runs the lookup per call.
return function (props = {}) {
const classes = [];
if (base) classes.push(base); // skip an empty base so it can't add a stray space
for (const variantName of Object.keys(variants)) {
// Presence, not truthiness: use the prop only if the key was supplied,
// otherwise fall back to the default. An explicit `undefined` still counts
// as "supplied" and therefore does NOT trigger the default.
const chosen =
variantName in props ? props[variantName] : defaultVariants[variantName];
// Look the chosen value up in this variant's table. Unknown value → undefined.
const className = variants[variantName][chosen];
// Only push real strings. A missing match contributes nothing.
if (className) classes.push(className);
}
return classes.join(' ');
};
}
module.exports = { classVarianceAuthority };
Two shifts turn Attempt 2 into a correct solution. First, variantName in props replaces props[key] || default — in asks "did the caller supply this key?", which is exactly the rule the spec wants, and it keeps a present-but-unknown value distinct from an absent one. Second, pushing only truthy className values into the array, then join(' '), means the only thing that can ever sit between two classes is a single space; empty base, unknown values, and unset variants all simply don't enter the array.
Take this config and the call button({ intent: 'primary', size: 'lg' }), where the config also declares a tone variant with no default:
const button = classVarianceAuthority({
base: 'btn',
variants: {
intent: { primary: 'bg-blue-500' },
size: { sm: 'text-sm', lg: 'text-lg' },
tone: { soft: 'opacity-80' },
},
defaultVariants: { intent: 'primary' },
});
classes = []
base is 'btn' (truthy) → classes = ['btn']
key 'intent':
'intent' in props? yes → chosen = 'primary'
variants.intent['primary'] → 'bg-blue-500' (truthy) → push
→ classes = ['btn', 'bg-blue-500']
key 'size':
'size' in props? yes → chosen = 'lg'
variants.size['lg'] → 'text-lg' (truthy) → push
→ classes = ['btn', 'bg-blue-500', 'text-lg']
key 'tone':
'tone' in props? no → chosen = defaultVariants.tone = undefined
variants.tone[undefined] → undefined (falsy) → skip
return classes.join(' ') → 'btn bg-blue-500 text-lg'
tone had neither a prop nor a default, so chosen was undefined, the lookup missed, and nothing was added — no crash, no stray space.
The same call shows all three resolution cases a single variant can hit — prop present and known, prop absent (default), and prop present but unknown:
|| or ?? instead of in. props[key] ?? default looks correct but treats an explicitly-passed undefined (or '' for ||) as "absent" and swaps in the default. The spec wants the default only when the key is genuinely missing. Use key in props to test presence, then read the prop. The diagram below contrasts the two.in, chosen becomes the unknown value (say 'danger'), and variants[key]['danger'] is undefined. The if (className) guard drops it. If you instead wrote chosen = (variants[key][value] ?? variants[key][default]), an unknown value would wrongly inherit the default's class. Don't re-introduce the fallback at the lookup step.+= or pushing a value before checking it lets undefined or an empty string into the output. Push only truthy classes into an array and call join(' ') once — that's the only construction that guarantees no doubled, leading, or trailing space.base adding a stray space. If base is '' and you start the array with [base], the join produces a leading space: ' text-lg'. Guard with if (base) classes.push(base) so an empty base never enters the array.button() passes props = undefined, and undefined[key] throws. Default the parameter (props = {}) so a no-argument call resolves every variant to its default.variants/defaultVariants always exist. A config like { base: 'btn' } has no variants key. Destructure with defaults (variants = {}, defaultVariants = {}) so iterating and looking up are safe on a base-only config.compoundVariants array — extra classes applied only when a combination of variant values is active (e.g. intent: 'primary' AND size: 'lg' together add a shadow). After resolving the base variants, you'd iterate the compound rules, keep each one whose every condition matches the chosen values, and append its classes. It layers cleanly on top of this solution because it runs after the per-key resolution.tailwind-merge. Two variants can emit conflicting utilities — text-sm and text-lg both set font size, and the last one should win. The real cva pairs with tailwind-merge to dedupe conflicting Tailwind classes so the final string has no contradictions. Here we keep every class verbatim; wiring in a merge step would post-process the joined output.true/false (a boolean prop like disabled) and lets each class entry be an array of strings rather than one string. Supporting both means coercing the prop to a string key (String(value)) before the lookup and flattening array values before pushing — small extensions to the resolution and collection steps.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.