Tailwind Integration — cn() and CVA
When to Use
Use
cn()and CVA in every component that uses Tailwind classes.cn()handles merging; CVA handles variant definitions. Both are foundational — not optional.Tailwind v4 note (verified): This project uses Tailwind CSS 4.2.0. In v4, there is no
tailwind.config.ts. All configuration is CSS-first using@import "tailwindcss"and@theme inline { }in your CSS file.
Decision
| If you need... | Use... | Why |
|---|---|---|
| Merge static + conditional classes | cn() (clsx + tailwind-merge) |
Resolves Tailwind class conflicts; removes duplicates |
Accept className override from caller |
cn(internalClasses, props.className) |
Last-wins; caller overrides base styles cleanly |
| Define size/color/state variants | CVA cva() |
Type-safe variant map; generates correct class set per variant combo |
| Style multi-slot compound components | tailwind-variants tv() |
Supports per-slot variants; CVA doesn't handle slots natively |
| Simple conditional class (no variants) | cn('base', condition && 'conditional') |
Inline; no CVA needed for one-off conditions |
Pattern
The cn() utility (define once, use everywhere):
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// clsx 2.1.1: handles strings, arrays, objects, conditionals → string
// tailwind-merge 3.5.0: resolves conflicting class groups (e.g. p-2 p-4 → p-4)
CVA 0.7.1 — verified exports and usage:
// CVA exports: cva, cx (= clsx re-export), VariantProps (type only)
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
{
variants: {
variant: { primary: 'bg-primary text-primary-foreground', ghost: 'hover:bg-accent' },
size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4', lg: 'h-12 px-8 text-lg' },
},
defaultVariants: { variant: 'primary', size: 'md' },
}
);
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ variant, size, className, ...props }: ButtonProps) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
// cn() wraps CVA output — CVA uses clsx internally but NOT tailwind-merge
// passing className last ensures caller overrides win via tailwind-merge
Common Mistakes
- Wrong: Template literals for conditional classes → Right: Always use
cn(); template literals break tailwind-merge conflict resolution - Wrong: Installing
clsxwithouttailwind-merge→ Right: clsx alone doesn't resolve Tailwind conflicts (e.g.,p-2 p-4both survive) - Wrong: Calling
cn()outside the component → Right:cn()must be called during render; result won't update on prop changes otherwise - Wrong: Mixing CVA output and ad-hoc classes with string concatenation → Right: Always
cn(cvaOutput, className); CVA uses clsx but NOT tailwind-merge internally - Wrong: Omitting
defaultVariantsin CVA → Right: Without defaults, all variant props become required - Wrong: Using
cxfrom CVA instead ofcn→ Right: CVA'scxis just clsx, not tailwind-merge; use yourcn()utility
See Also
- Variant Management
- Composition Patterns
- Reference: CVA docs
- Reference: tailwind-merge
- Reference: shadcn/ui utils
- Reference: tailwind-variants vs CVA