Independent props that combine predictably (e.g., size, shape, density)
- Conditional props: Props that only apply when another prop meets a specific value
- Static styling: Single-state components with no variant logic
Step 2: Implement CVA for Orthogonal Variants Only
CVA expects each variant key to be independent. When this assumption holds, CVA collapses the combinatorial matrix into a declarative object while generating strict TypeScript types.
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/utils/class-merger';
const cardVariants = cva(
'relative flex flex-col rounded-lg border border-surface-subtle bg-surface-primary shadow-sm transition-shadow hover:shadow-md',
{
variants: {
density: {
compact: 'p-3 gap-2',
default: 'p-4 gap-3',
spacious: 'p-6 gap-4',
},
elevation: {
flat: 'shadow-none',
raised: 'shadow-lg',
floating: 'shadow-xl',
},
},
defaultVariants: {
density: 'default',
elevation: 'flat',
},
}
);
export type CardProps = React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof cardVariants> & {
children: React.ReactNode;
};
export function Card({ density, elevation, className, children, ...props }: CardProps) {
return (
<div className={cn(cardVariants({ density, elevation }), className)} {...props}>
{children}
</div>
);
}
Architecture Rationale: The VariantProps utility extracts the exact union types from cardVariants. density becomes 'compact' | 'default' | 'spacious'. elevation becomes 'flat' | 'raised' | 'floating'. The type system and runtime configuration remain synchronized. Adding a new density value requires updating only the variants object; the prop interface updates automatically.
Step 3: Handle Conditional Props Outside CVA
CVA cannot enforce conditional prop existence. If a prop only makes sense when another prop matches a specific value, forcing it into the variants object breaks type safety. Consumers can legally pass invalid combinations that compile cleanly but produce incoherent styling.
const statusThemeMap = {
info: 'border-info-subtle bg-info-surface text-info-default',
success: 'border-success-subtle bg-success-surface text-success-default',
warning: 'border-warning-subtle bg-warning-surface text-warning-default',
error: 'border-error-subtle bg-error-surface text-error-default',
};
export type AlertProps = React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof alertVariants> & {
status?: keyof typeof statusThemeMap;
};
export function Alert({ variant, status, className, children, ...props }: AlertProps) {
const statusClasses = variant === 'filled' && status ? statusThemeMap[status] : '';
return (
<div className={cn(alertVariants({ variant }), statusClasses, className)} {...props}>
{children}
</div>
);
}
Architecture Rationale: The status prop lives outside CVA. The conditional variant === 'filled' && status acts as a load-bearing seam. CVA handles the orthogonal center of the design space; the conditional patches the contextual edges. This preserves type safety while preventing invalid prop combinations from reaching the DOM.
Step 4: Reserve compoundVariants for Cross-Prop Intersections
When two orthogonal variants produce a unique class combination that cannot be derived from their individual definitions, compoundVariants is the correct tool. It does not enforce conditional types; it resolves class collisions and applies targeted overrides.
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full font-medium transition-colors',
{
variants: {
shape: { circle: 'aspect-square rounded-full', pill: 'px-3' },
scale: { sm: 'text-xs h-5', md: 'text-sm h-6', lg: 'text-base h-8' },
},
compoundVariants: [
{ shape: 'circle', scale: 'lg', className: 'text-lg h-10 w-10' },
{ shape: 'pill', scale: 'sm', className: 'px-2.5' },
],
defaultVariants: { shape: 'pill', scale: 'md' },
}
);
Architecture Rationale: compoundVariants excels at dense cross-product matrices. It eliminates manual conditionals at the call site while keeping the variant definitions orthogonal. Use it when the intersection produces a unique visual state, not when the intersection should invalidate the prop combination.
Step 5: Strip CVA Entirely for Static Components
Components with rigid structures and zero variant logic should never import CVA. The overhead is unnecessary, and the empty scaffolding signals architectural confusion.
export type BreadcrumbProps = React.HTMLAttributes<HTMLElement> & {
items: Array<{ label: string; href?: string }>;
};
export function Breadcrumb({ items, className, ...props }: BreadcrumbProps) {
return (
<nav aria-label="Breadcrumb" className={cn('flex items-center gap-1 text-sm', className)} {...props}>
<ol className="flex items-center gap-1">
{items.map((item, index) => (
<li key={index} className="flex items-center gap-1">
{index > 0 && <span className="text-surface-muted">/</span>}
{item.href ? (
<a href={item.href} className="hover:underline">{item.label}</a>
) : (
<span className="font-medium">{item.label}</span>
)}
</li>
))}
</ol>
</nav>
);
}
Architecture Rationale: The component inherits sizing from its context. It has no variant matrix. It requires no type inference. Stripping CVA reduces cognitive load, eliminates dead imports, and clarifies that the component's purpose is structural, not stylistic.
Pitfall Guide
1. The Empty Variants Trap
Explanation: Initializing cva() with variants: {} and importing VariantProps creates a circular dependency that resolves to {}. The type system gains nothing, and the runtime executes a function that returns the base class string unmodified.
Fix: Audit components before scaffolding. If a component has zero variant logic, remove the CVA import entirely. Use cn() directly for class merging.
2. Forcing Non-Orthogonal Props into CVA
Explanation: Placing interdependent props (e.g., color that only works with variant="solid") inside the variants object breaks type safety. TypeScript will allow <Component variant="ghost" color="primary" />, which compiles but produces broken styling.
Fix: Extract conditional props into lookup maps or conditional expressions outside the cva() call. Use explicit prop guards to apply classes only when the dependency is met.
3. Ignoring VariantProps Type Inference
Explanation: Developers sometimes define prop interfaces manually instead of using VariantProps<typeof config>. This decouples the type system from the runtime configuration, requiring manual updates whenever variants change.
Fix: Always derive prop types from the CVA configuration using VariantProps. This ensures bidirectional synchronization and eliminates drift between design tokens and TypeScript interfaces.
4. Overcomplicating with compoundVariants
Explanation: Using compoundVariants to enforce conditional prop existence or to handle logic that belongs in the component body. compoundVariants only resolves class combinations; it cannot modify the TypeScript interface.
Fix: Reserve compoundVariants for dense cross-product matrices where specific intersections require unique class overrides. Use conditional rendering or prop guards for logic that affects component behavior or validity.
5. Missing Tree-Shaking Boundaries
Explanation: Importing CVA utilities in files that don't use them, or bundling CVA configurations in a way that prevents dead code elimination. While CVA is tree-shakeable, unused imports and dead cva() blocks still pollute the module graph.
Fix: Run automated lint sweeps targeting unused VariantProps imports and empty variants objects. Configure your bundler to flag unused CVA calls during CI.
6. Copy-Pasting Type Imports After Refactors
Explanation: Removing variant logic during a refactor but forgetting to delete the import { cva, type VariantProps } statement. The file compiles, but the imports are dead weight.
Fix: Integrate unused-imports ESLint rules with strict TypeScript checking. Run tsc --noEmit in CI to catch type-only imports that reference deleted configurations.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Component has 3+ independent variant props | CVA with VariantProps inference | Collapses combinatorial matrix, enforces type safety | Low (single source of truth) |
| Prop only applies when another prop matches a value | Conditional lookup map + cn() | Preserves type safety, prevents invalid combinations | Medium (manual prop guards) |
| Two orthogonal props produce unique class combination | compoundVariants inside CVA | Resolves cross-product intersections cleanly | Low (declarative override) |
| Component has zero variant logic | Direct cn() with static classes | Eliminates dead scaffolding, reduces cognitive load | Minimal (no abstraction) |
| Legacy component with mixed orthogonal/conditional props | Refactor into CVA + conditional seam | Restores type safety, clarifies architectural boundaries | High (one-time refactor) |
Configuration Template
// utils/cva-config.ts
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/utils/class-merger';
// Base configuration for consistent styling patterns
export const baseTransition = 'transition-all duration-200 ease-in-out';
export const baseFocus = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-default';
// Reusable variant factory
export function createVariants<T extends Record<string, Record<string, string>>>(
baseClasses: string,
config: {
variants: T;
defaultVariants?: Partial<Record<keyof T, string>>;
compoundVariants?: Array<{ [K in keyof T]?: string } & { className: string }>;
}
) {
return cva(baseClasses, config);
}
// Type-safe component wrapper
export function withVariants<P extends Record<string, unknown>>(
Component: React.ComponentType<P>,
variants: ReturnType<typeof cva>
) {
type Props = P & VariantProps<typeof variants>;
return Component as React.ComponentType<Props>;
}
Quick Start Guide
- Initialize lint rules: Add
@typescript-eslint/no-unused-vars and eslint-plugin-unused-imports to your ESLint configuration. Set ignoreRestSiblings: false to catch dead VariantProps imports.
- Create a variant audit script: Run a recursive grep across
src/components/ for cva( and VariantProps. Flag files where variants: {} or imports are unused.
- Refactor in batches: Start with components that have zero variant logic. Strip CVA, replace with
cn(), and verify TypeScript compilation.
- Implement conditional seams: For components with interdependent props, extract conditional logic into lookup maps. Test prop combinations to ensure invalid states are rejected at compile time.
- Validate type inference: Run
tsc --noEmit and verify that VariantProps<typeof config> generates the expected union types. Add test cases for edge-case prop combinations.