SecondaryButton는 내부적으로 ButtonBase를 사용하고 있었고, typography는 아래처럼 분리돼 있었습니다.
const typo = {
primary: 'text-14 md:text-16 font-bold',
secondary: 'text-14 md:text-16 font-medium',
label: 'text-14 md:text-16 font-medium',
};
const toneClassMap = {
secondary: {
default:
'bg-primary-500 text-white hover:bg-primary-hover disabled:bg-white disabled:text-gray-200 disabled:border disabled:border-gray-200',
accent:
'bg-white text-gray-600 border border-gray-200 disabled:bg-white disabled:text-gray-200 disabled:border-gray-200',
},
};
className={cx(
baseClassName,
shapes[variant],
typo[variant],
toneClassMap[variant][tone],
className
)}
겉으로 보면 text-14와 text-gray-600은 서로 다른 역할이라 같이 살아야 합니다.
text-14: 글자 크기
text-gray-600: 글자 색상
그런데 실제로는 text-14가 사라져서, 모바일 구간에서 글자 크기가 14px로 줄지 않았습니다.
프로젝트는 Tailwind CSS v4 스타일로 typography 토큰을 직접 정의하고 있었습니다.
@theme inline {
--text-14: 0.875rem; /* 14px */
--text-16: 1rem; /* 16px */
}
즉, text-14 자체는 잘못된 클래스가 아니었습니다.
문제는 클래스 생성이 아니라, 클래스를 합치는 과정에 있었습니다.
프로젝트의 cx() 유틸은 내부적으로 tailwind-merge를 사용하고 있었습니다.
import { cx as cvaCX } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';
export const cx = (...inputs: ClassValue[]): string => twMerge(cvaCX(inputs));
tailwind-merge는 충돌하는 Tailwind 클래스를 자동으로 정리해 주는 라이브러리입니다.
예를 들어 p-2 p-4가 같이 있으면 마지막 p-4만 남기는 식입니다.
문제는 이 라이브러리가 기본 설정만으로는 text-14를 프로젝트의 커스텀 font-size 토큰으로 알지 못했다는 점입니다.
원인을 찾을 때는 아래 순서로 확인했습니다.
직접 재현해보면 원인이 분명해집니다.
twMerge('text-14 text-gray-600')
// 결과: 'text-gray-600'
즉, tailwind-merge가 text-14와 text-gray-600를 같은 text-* 그룹으로 보고, 뒤에 오는 text-gray-600만 남기고 있었습니다.
여기서 중요한 포인트는 md:text-16은 살아남았다는 점입니다.
text-14는 기본 구간 클래스
md:text-16은 md 반응형 구간 클래스
둘은 modifier가 다르기 때문에 md:text-16은 유지되고, 기본 구간의 text-14만 지워졌습니다.
그래서 결과적으로 모바일에서는 기본 글자 크기처럼 보이고, md 이상에서는 16px이 적용되는 상태가 됐습니다.
text-[14px] md:text-[16px] font-medium
이 방법의 이유는 단순합니다.
하지만 단점도 있습니다.
--text-14, --text-16처럼 이미 정의한 토큰의 의미가 약해짐import { extendTailwindMerge } from 'tailwind-merge';
이 방법의 이유는 구조적인 해결이기 때문입니다.
text-14, text-16을 프로젝트의 정식 font-size 토큰으로 인식시킬 수 있음단점은 한 가지입니다.
초기에 merge 설정을 한 번 관리해줘야 함
이 방법도 이론상 가능합니다.
클래스가 지워지지 않으니 당장 문제는 없어질 수 있음
하지만 추천하기 어렵습니다.
px-3 px-4, rounded-lg rounded-xl 같은 진짜 충돌 정리가 사라짐이번에는 2번, 즉 tailwind-merge 확장 방식을 선택했습니다.
선택한 이유는 명확했습니다.
즉, “버튼 하나만 급하게 고치는 방식”보다 “토큰 시스템 자체를 제대로 이해시키는 방식”이 더 맞는 해결책이었습니다.
import { cx as cvaCX } from 'class-variance-authority';
import type { ClassValue } from 'clsx';
import { extendTailwindMerge } from 'tailwind-merge';
const twMerge = extendTailwindMerge({
extend: {
theme: {
text: [
'10',
'12',
'13',
'14',
'16',
'18',
'20',
'24',
'32',
'14-body',
'16-body',
'18-body',
'20-body',
],
},
},
});
export const cx = (...inputs: ClassValue[]): string => twMerge(cvaCX(inputs));
핵심은 theme.text에 프로젝트의 typography 토큰을 등록한 것입니다.
이제 tailwind-merge는 text-14를 “색상 후보”가 아니라 “font-size 토큰”으로 이해합니다.
적용 전에는 아래 병합 결과에서 text-14가 사라졌습니다.
twMerge('text-14 text-gray-600')
// 'text-gray-600'
적용 후에는 두 클래스가 함께 유지됩니다.
twMerge('text-14 text-gray-600')
// 'text-14 text-gray-600'
실제 UI 관점에서 보면 결과는 다음과 같습니다.
정리하면, 이번 이슈는 Tailwind CSS의 문제가 아니라 tailwind-merge가 프로젝트의 커스텀 typography 토큰을 모르고 있었던 것이 원인이었고, 가장 좋은 해결책은 토큰 체계를 유지한 채 merge 설정을 확장하는 것이었습니다.