콘텐츠로 이동

11. Tailwind 실전

긴 클래스는 문장이 아니라 표로 읽는다

익숙해지면 이건 하나의 긴 문자열이 아니라 그룹의 나열로 보인다.

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-50
transition-colors ← 애니메이션

클래스를 항상 같은 순서로 쓰면 눈이 자동으로 그룹을 나눈다. 그 순서를 사람이 지키는 대신 도구가 강제하게 만드는 것이 첫 번째 규칙이다.

규칙 1 — 클래스 정렬을 자동화한다

섹션 제목: “규칙 1 — 클래스 정렬을 자동화한다”
Terminal window
pnpm add -D prettier prettier-plugin-tailwindcss
prettier.config.mjs
export default {
plugins: ['prettier-plugin-tailwindcss'],
}
  • 저장할 때마다 클래스가 공식 권장 순서로 재배열된다
  • 팀원마다 순서가 다를 일이 없어진다
  • diff가 깨끗해진다 — 같은 스타일이면 항상 같은 문자열
  • 중복 클래스도 눈에 띄게 된다

규칙 2 — cn() 유틸리티를 만든다

섹션 제목: “규칙 2 — 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-2p-4에 흡수된다는 것까지 안다.

cn()이 여는 것 — 오버라이드 가능한 컴포넌트

섹션 제목: “cn()이 여는 것 — 오버라이드 가능한 컴포넌트”
components/ui/card.tsx
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" /> // 테두리 색만

규칙 3 — variant는 cva로 관리한다

섹션 제목: “규칙 3 — variant는 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' },
}
)

variantsize처럼 축이 여럿이면 독립적으로 조합된다 — variant: 'outline', size: 'sm' 같은 조합을 따로 정의하지 않아도 된다.

compoundVariants — 조합에만 적용되는 규칙

섹션 제목: “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' }
/* ❌ 유혹적이지만 하지 말 것 */
.btn {
@apply inline-flex items-center rounded-md bg-primary px-4 py-2;
}
  • Tailwind가 없앤 문제(이름 짓기, 전역 CSS, 못 지우는 코드)를 그대로 되살린다
  • .btn이 어디서 쓰이는지 다시 알 수 없게 된다
  • 결국 .btn-sm, .btn-primary-outline이 생기고 BEM으로 되돌아간다
  • Tailwind 제작자 본인이 **“이걸 만든 걸 후회한다”**고 여러 차례 언급했다

반복이 보이면 CSS 클래스가 아니라 컴포넌트를 만든다.

  • 서드파티가 뱉는 마크업을 스타일링해야 할 때 (에디터, 캘린더 위젯)
  • 마크다운 렌더 결과h1, p 같은 태그에 직접 걸어야 할 때
  • 컴포넌트로 감쌀 수 없는 글로벌 리셋
/* 마크다운 본문 — 마크업을 우리가 만들지 않는다 */
.prose h2 {
@apply mt-8 text-2xl font-semibold tracking-tight;
}

공통점은 마크업에 클래스를 붙일 수 없는 상황이라는 것. 그게 아니라면 컴포넌트가 답이다.

커스텀 유틸리티가 필요하면 @utility

섹션 제목: “커스텀 유틸리티가 필요하면 @utility”

v4는 @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로 만든 클래스는 그게 안 된다.

규칙 5 — 언제 컴포넌트로 뽑을 것인가

섹션 제목: “규칙 5 — 언제 컴포넌트로 뽑을 것인가”
상황 판단
같은 클래스 조합이 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-widthauto라 콘텐츠보다 작아지지 않는다 — 모르면 한 시간을 태운다.

  1. prettier-plugin-tailwindcss를 넣는다 — 무조건, 첫날에

  2. lib/utils.tscn()을 확인한다shadcn init이 만들어 준다

  3. @apply 사용을 리뷰 대상으로 정한다 — 예외 세 가지 외에는 반려

  4. 임의 값 [...]이 두 번 이상 반복되면 토큰으로 올린다 (12장)

  5. 컴포넌트 추출 기준을 팀과 합의한다 — 위 표를 그대로 써도 된다

  • prettier-plugin-tailwindcss로 클래스 순서를 자동 정렬한다 — 무조건 넣는다
  • cn() = clsx + tailwind-merge. 조건부 조립 + 충돌 해소
  • 모든 컴포넌트가 className을 받아 cn()으로 병합 → 오버라이드 가능
  • variant가 셋 이상이면 cva. 타입도 함께 얻는다
  • @apply는 쓰지 않는다. 마크업에 클래스를 못 붙이는 상황만 예외
  • 커스텀 유틸리티는 **@utility**로 만든다 (변형이 붙는다)
  • 추출 기준은 “앞으로도 같이 바뀔까?” 하나