클래스는 분명 붙었는데 스타일이 안 바뀌는 버그 때문에 한참 헤맸던 적이 있어요. 알고 보니 조건부 클래스끼리 충돌하고 있더라고요. media-crop 저장소의 src/lib/utils.ts는 딱 세 줄짜리 파일인데, FileUploader.tsx처럼 조건 따라 클래스 바뀌는 컴포넌트 전반에서 계속 쓰이더라고요.
세 줄짜리 유틸 함수
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
clsx는 문자열, 객체, 배열, false/undefined가 섞여 들어와도 참인 값만 걸러서 하나의 클래스 문자열로 합쳐줘요. 문제는 그다음이에요. Tailwind 클래스는 같은 속성을 가리키는 클래스가 동시에 있어도 문자열 자체론 충돌을 못 알아채더라고요.
실제로 충돌 났던 지점
<div
className={cn(
"flex flex-col items-center justify-center p-12 border-2 border-dashed rounded-2xl transition-all duration-300",
isDragging ? "border-primary scale-[1.02] bg-white/5" : "border-border hover:border-primary/50"
)}
>
FileUploader.tsx의 이 구조에서 기본 상태의 border-border랑 드래그 중 상태의 border-primary가 같은 border 색상 속성을 두고 경쟁해요. clsx만 썼으면 최종 문자열에 둘 다 나란히 들어가고, 브라우저는 CSS가 실제로 적용된 순서(마지막에 선언된 클래스)를 따르니까 의도한 색이 아닌 다른 색이 이겨버릴 수 있어요.
tailwind-merge가 마지막 값을 우선시켜줘요
twMerge는 Tailwind 클래스 이름 규칙을 알고 있어서, border-border랑 border-primary처럼 같은 속성 그룹에 속하는 클래스가 겹치면 뒤에 오는 값만 남기고 앞의 걸 지워줘요. 그래서 조건부로 클래스를 이어 붙여도 항상 마지막에 평가된 조건의 스타일이 실제로 적용돼요. clsx(inputs) 결과를 twMerge에 한 번 더 통과시키는 이 조합 덕분에, 조건부 클래스가 많아질수록 자주 겪는 "클래스는 붙었는데 스타일은 안 바뀐다"는 버그가 확실히 줄었어요.