
Tailwind CSS를 사용하다 보면 동적으로 클래스를 관리해야 할 때가 많습니다.
이때 실무에 필수적으로 등장하는 clsx, tailwind-merge (twMerge), 그리고 이 둘을 합친 cn에 대해 정리해보고, 더 나아가 여기에 cva가 더해지면 어떤 강력한 시너지가 나는지에 대해 정리해보았습니다!
tailwind를 설치하는 과정은 아래 블로그를 참고하세요!
👉🏻 https://javascript.plainenglish.io/react-tailwind-reuseable-and-customizable-components-with-cva-clsx-and-tailwindmerge-combo-guide-c3756bdbbf16
clsx는 조건에 따라 클래스를 넣거나 뺄 때 사용하는 아주 가벼운 라이브러리입니다.
사용 예:
<div className={`bg-blue-500 ${isActive ? 'text-white' : ''} ${isDisabled && 'opacity-50'}`} />
위와 같은 기존의 템플릿 리터럴을 사용하면 조건부 로직이 지저분해진다는 문제점이 있습니다.
이때 clsx를 쓰면 객체나 배열 형태로 깔끔하게 조건을 처리할 수 있습니다.
import { clsx } from 'clsx';
clsx('bg-blue-500', {
'text-white': isActive, // isActive가 true일 때만 적용
'opacity-50': isDisabled // isDisabled가 true일 때만 적용
});
tailwind-merge는 Tailwind CSS의 스타일 충돌을 해결해 주는 라이브러리입니다.
CSS 파일이 생성될 때의 순서가 중요하기 때문에, Tailwind 클래스는 단순히 뒤에 적었다고 해서 덮어씌워지는 것이 아닙니다. (Cascade 문제)
// 의도 = 외부에서 받은 className이 기본 스타일(p-4)을 덮어쓰길 원함
function Button({ className }) {
return <button className={`p-4 bg-blue-500 ${className}`}>Click</button>;
}
// 사용: <Button className="p-8" />
// 결과: 실제로는 p-4가 적용될 수도 있음 (Tailwind 정의 순서에 따라 다름) -> 예측 불가능하다는 문제!
왜 굳이
twMerge가 필요한가? (Cascade 문제 더 자세히)
tailwind-merge의 핵심 존재 이유는 CSS의 동작 원리(Cascade) 때문입니다.일반적인 CSS에서는 나중에 작성된 클래스가 이깁니다. 하지만 Tailwind는 util 클래스이기 때문에, HTML
class속성에 적힌 순서가 아니라, CSS 파일이 빌드될 때 정의된 순서가 적용 우선순위를 결정합니다.// 예: Tailwind 설정상 'px-4'가 'p-8'보다 뒤에 정의되어 있다면? <button className="p-8 px-4" /> // 순서 상관없이 정의된 순서에 따라 적용됨!이 예측 불가능함을 해결하기 위해
twMerge를 사용해서 논리적으로 같은 그룹(padding, margin, color 등)을 감지하고, 개발자가 의도한 '맨 뒤에 적은 클래스'만 남기고 앞의 스타일을 진짜 지워버리기 위함입니다!
이때 twMerge는 같은 CSS 속성(예: padding, color)을 건드리는 클래스가 두 개 이상일 때, 나중에 들어온 클래스가 확실히 이기도록 앞의 클래스를 덮어써줍니다!
cn은 라이브러리 이름이 아니라, 개발자들이 관습적으로 만드는 유틸리티 함수의 이름입니다.
조건부 렌더링(clsx)도 필요하고 스타일 충돌해결(twMerge)도 필요할 때 cn 유틸 함수를 사용합니다.
utils 폴더나 파일 안쪽에 위치하며, 모든 컴포넌트에서 이 함수 하나만 불러와서 씁니다.
// utils/cn.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
false, undefined, null 등을 제거하고 조건부 객체를 문자열로 변환.cn 함수 사용 예시:
// 기본은 빨강, 특정 조건엔 초록(빨강 덮어쓰기), 비활성화면 투명도 조절
<div className={cn(
"bg-red-500 p-4", // 기본 스타일
isSuccess && "bg-green-500", // 조건부 (bg-red-500을 덮어씀)
isDisabled && "opacity-50" // 조건부 추가
)} />
Tailwind로 재사용 가능한 컴포넌트(버튼, 카드 등)를 만들 때는 props로 넘어오는 className이 내부 스타일을 덮어써야 하는 경우가 많으므로 cn 유틸리티를 만들어 사용하는 것이 사실상 필수입니다.
cn과 함께 반드시 언급되는 것이 cva (Class Variance Authority) 입니다. cn이 "클래스 충돌 해결"을 담당한다면, cva는 "디자인 종류(Variant) 관리"를 담당합니다.
왜 cva를 쓸까?
버튼 하나를 만들 때
primary,secondary,outline같은 종류(variant)와sm,lg같은 크기(size)를 조합해야 할 때,if/else문이나 삼항 연산자로 처리하면 코드가 지저분해집니다.cva는 이를 체계적인 "스타일 설정 객체"로 만들어주기 때문입니다.
cva 설치하고,
npm install class-variance-authority
코드를 보며 예시를 이해해볼까요?
아래는 cn과 cva를 합쳐서 확장성 있는 버튼 컴포넌트를 만드는 패턴입니다.
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/cn"; // cn 함수
// 1. 스타일 정의 (기본값 + 변수들)
const buttonVariants = cva(
// 공통 베이스 스타일
"flex justify-center items-center rounded-xl transition-all active:scale-95 font-bold",
{
variants: {
variant: {
default: "bg-slate-900 text-white hover:bg-slate-800", // 기본 색상
primary: "bg-blue-500 text-white hover:bg-blue-600", // 파란 버튼
outline: "border-2 border-slate-200 bg-transparent hover:bg-slate-100", // 테두리만
},
size: {
default: "h-10 py-2 px-4",
sm: "h-9 px-3 text-xs",
lg: "h-12 px-8 text-lg",
},
},
// 기본값 설정 (props를 안 넣었을 때)
defaultVariants: {
variant: "default",
size: "default",
},
}
);
// 2. 타입 정의
// HTML 버튼 속성 + cva가 만들어준 Variant 타입(variant, size)을 합침
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
// 3. 컴포넌트 구현
const Button = ({ className, variant, size, ...props }: ButtonProps) => {
return (
<button
// cva로 만든 variant 스타일에 -> 외부에서 들어온 className을 cn으로 병합(덮어쓰기 가능하게)
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
};
export default Button;
컴포넌트를 위와 같이 지정하면 해당 컴포넌트는 아래와 같이 페이지에서 직관적으로 사용할 수 있습니다!
// 1. 기본 사용 (defaultVariants 적용)
<Button>Click me</Button>
// 2. 옵션 사용 (cva가 처리)
<Button variant="primary" size="lg">Big Blue Button</Button>
// 3. 커스텀 스타일 덮어쓰기 (cn이 처리)
// bg-blue-500을 bg-red-500이 덮어써서 빨간 버튼이 됨 (충돌 해결!)
<Button variant="primary" className="bg-red-500">Emergency</Button>
거기다가 이 cva는 스토리북과의 호환성도 아주 좋다고 합니다.
prop 으로 받는 값을 통해 다른 디자인을 보여주는 cva의 패턴은 storybook과도 아주 잘 맞는 것을 예상할 수 있겠죠?
cva 함수 안에서도 Tailwind 자동완성(Intellisense)을 쓰기 위한 설정 팁을 공유합니다.
VS Code 설정(settings.json)에 정규식을 추가하면 cva(...) 안에 문자열을 적을 때도 Tailwind 클래스 자동완성이 작동합니다!!! 이 설정을 안 하면 문자열로만 인식되어 불편해요

"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
]
json형식의 파일이니 각 객체를 나눌때 ,로 나누어주는것을 기억하면서, 최상위 중괄호 내부에 넣어주면 됩니다.

참고: https://xionwcfm.tistory.com/325
이러한 기능들과 함께라면 tailwind를 싫어하는 사람들도 tailwind를 눈여겨 보지 않을까.. 싶습니다 ㅎㅎ
저도 단순히 Tailwind CSS를 쓰는 것에 그치지 않고 좀 더 활용할 수 있는 방안을 계속 찾아봐야겠습니다!!!