clsx — 조건부 조립
clsx('p-4', isLarge && 'p-8', { 'bg-red-500': hasError,})// → "p-4 p-8 bg-red-500"긴 클래스는 문장이 아니라 표로 읽는다
익숙해지면 이건 하나의 긴 문자열이 아니라 그룹의 나열로 보인다.
inline-flex items-center justify-center gap-2 ← 레이아웃h-9 px-4 py-2 ← 크기text-sm font-medium whitespace-nowrap ← 타이포bg-primary text-primary-foreground ← 색상rounded-md border border-transparent ← 테두리hover:bg-primary/90 focus-visible:ring-2 ← 상태disabled:opacity-50transition-colors ← 애니메이션클래스를 항상 같은 순서로 쓰면 눈이 자동으로 그룹을 나눈다. 그 순서를 사람이 지키는 대신 도구가 강제하게 만드는 것이 첫 번째 규칙이다.
pnpm add -D prettier prettier-plugin-tailwindcssexport default { plugins: ['prettier-plugin-tailwindcss'],}cn() 유틸리티를 만든다조건부 클래스를 붙이다 보면 반드시 이 문제를 만난다.
// 기본은 p-4, 특정 상황에선 p-8<div className={`p-4 ${isLarge ? 'p-8' : ''}`} />결과 문자열은 "p-4 p-8"이다.
CSS에서는 나중에 정의된 규칙이 이긴다. 클래스를 쓴 순서가 아니다.
Tailwind가 생성한 CSS에서 .p-4와 .p-8 중 누가 뒤에 있는지에 따라 결과가 갈린다.
그래서 “뒤에 오는 클래스가 이기게” 병합해 주는 함수가 필요하다.
// lib/utils.ts — shadcn/ui init이 자동으로 만들어 주는 파일import { clsx, type ClassValue } from 'clsx'import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs))}clsx — 조건부 조립
clsx('p-4', isLarge && 'p-8', { 'bg-red-500': hasError,})// → "p-4 p-8 bg-red-500"tailwind-merge — 충돌 해소
twMerge('p-4 p-8') // → "p-8"twMerge('px-2 p-4') // → "p-4"twMerge('text-sm text-lg') // → "text-lg"tailwind-merge는 어떤 유틸리티끼리 충돌하는지 알고 있다.
px-2가 p-4에 흡수된다는 것까지 안다.
cn()이 여는 것 — 오버라이드 가능한 컴포넌트import { cn } from '@/lib/utils'
export function Card({ className, ...props }: React.ComponentProps<'div'>) { return ( <div className={cn('rounded-lg border bg-card p-6 shadow-sm', className)} {...props} /> )}// 쓰는 쪽에서 필요한 것만 덮어쓴다<Card /> // 기본<Card className="p-4" /> // 패딩만 좁게 — p-6가 제대로 밀려난다<Card className="border-destructive" /> // 테두리 색만cva로 관리한다variant가 셋을 넘어가면 삼항 연산자가 무너진다.
import { cva } from 'class-variance-authority'
const badgeVariants = cva( 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold', { variants: { variant: { default: 'border-transparent bg-primary text-primary-foreground', secondary: 'border-transparent bg-secondary text-secondary-foreground', destructive: 'border-transparent bg-destructive text-white', outline: 'text-foreground', }, }, defaultVariants: { variant: 'default' }, })variant와 size처럼 축이 여럿이면 독립적으로 조합된다 —
variant: 'outline', size: 'sm' 같은 조합을 따로 정의하지 않아도 된다.
compoundVariants — 조합에만 적용되는 규칙const button = cva('...', { variants: { variant: { default: '...', outline: '...' }, size: { sm: 'h-8 px-3', lg: 'h-10 px-8' }, }, compoundVariants: [ { variant: 'outline', size: 'sm', class: 'border-2', // outline이면서 sm일 때만 }, ], defaultVariants: { variant: 'default', size: 'default' },})타입도 함께 얻는다.
import { type VariantProps } from 'class-variance-authority'type ButtonProps = VariantProps<typeof button>// { variant?: 'default' | 'outline', size?: 'sm' | 'lg' }@apply를 쓰지 않는다/* ❌ 유혹적이지만 하지 말 것 */.btn { @apply inline-flex items-center rounded-md bg-primary px-4 py-2;}.btn이 어디서 쓰이는지 다시 알 수 없게 된다.btn-sm, .btn-primary-outline이 생기고 BEM으로 되돌아간다반복이 보이면 CSS 클래스가 아니라 컴포넌트를 만든다.
@apply가 정당한 드문 경우h1, p 같은 태그에 직접 걸어야 할 때/* 마크다운 본문 — 마크업을 우리가 만들지 않는다 */.prose h2 { @apply mt-8 text-2xl font-semibold tracking-tight;}공통점은 마크업에 클래스를 붙일 수 없는 상황이라는 것. 그게 아니라면 컴포넌트가 답이다.
@utilityv4는 @apply 대신 진짜 유틸리티를 추가하는 방법을 제공한다.
@import "tailwindcss";
@utility scrollbar-none { scrollbar-width: none; &::-webkit-scrollbar { display: none; }}
@utility text-pretty-balance { text-wrap: pretty;}이제 scrollbar-none이 진짜 유틸리티가 된다 — hover:, md: 같은 변형도 붙일 수 있다.
@apply로 만든 클래스는 그게 안 된다.
| 상황 | 판단 |
|---|---|
| 같은 클래스 조합이 2번 나타남 | 그냥 둔다 |
| 3번 이상 + 함께 바뀔 것이 확실 | 컴포넌트로 뽑는다 |
| 클래스는 같은데 의미가 다름 | 뽑지 않는다 |
| 마크업 구조까지 같음 | 컴포넌트로 뽑는다 |
<!-- 중앙 정렬 --><div class="flex items-center justify-center"><div class="grid place-items-center">
<!-- 카드 그리드 --><div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- 좌우 끝 정렬 --><div class="flex items-center justify-between">
<!-- 컨테이너 --><div class="mx-auto w-full max-w-5xl px-4">
<!-- 스택 (자식 사이 간격만) --><div class="flex flex-col gap-4">
<!-- 텍스트 잘림 방지 (flex 안에서 필수) --><div class="flex"><span class="min-w-0 truncate">아주 긴 텍스트…</span></div>
<!-- 종횡비 유지 --><div class="relative aspect-video overflow-hidden rounded-lg">min-w-0은 flex 자식이 안 줄어드는 문제의 해결책이다.
flex 아이템의 기본 min-width가 auto라 콘텐츠보다 작아지지 않는다 —
모르면 한 시간을 태운다.
prettier-plugin-tailwindcss를 넣는다 — 무조건, 첫날에
lib/utils.ts의 cn()을 확인한다 — shadcn init이 만들어 준다
@apply 사용을 리뷰 대상으로 정한다 — 예외 세 가지 외에는 반려
임의 값 [...]이 두 번 이상 반복되면 토큰으로 올린다 (12장)
컴포넌트 추출 기준을 팀과 합의한다 — 위 표를 그대로 써도 된다
cn() = clsx + tailwind-merge. 조건부 조립 + 충돌 해소className을 받아 cn()으로 병합 → 오버라이드 가능cva. 타입도 함께 얻는다@apply는 쓰지 않는다. 마크업에 클래스를 못 붙이는 상황만 예외@utility**로 만든다 (변형이 붙는다)