콘텐츠로 이동
Study Note프론트엔드

10. Tailwind CSS v4

tailwind.config.js가 사라졌다

터미널 창
pnpm add tailwindcss @tailwindcss/postcss postcss
postcss.config.mjs
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config
app/globals.css
@import "tailwindcss";

tailwind.config.js가 없다. v4에서는 설정이 CSS 안으로 들어왔다. v3의 @tailwind base; @tailwind components; @tailwind utilities; 세 줄도 @import "tailwindcss" 한 줄로 대체됐다.

v3v4
설정 파일tailwind.config.jsCSS의 @theme
진입점@tailwind 3줄@import "tailwindcss"
PostCSS 플러그인tailwindcss + autoprefixer@tailwindcss/postcss 하나
content 경로 지정직접 배열로자동 탐지
색 공간rgb / hsloklch
커스텀 값 접근JS importCSS 변수로 항상 노출
빌드 엔진PostCSS 기반Rust 기반 (Oxide)

v3 설정 파일이 필요하면 @config "../tailwind.config.js";로 불러올 수 있다. 마이그레이션 중간 단계용이다.

@import "tailwindcss";
@theme {
--color-brand-500: oklch(0.72 0.11 178);
}

이 한 줄이 세 가지를 동시에 만든다.

  • bg-brand-500, text-brand-500, border-brand-500, fill-brand-500… 유틸리티 전부
  • var(--color-brand-500) — 일반 CSS 변수로도 쓸 수 있다
  • 자동완성 — 에디터가 이 값을 알게 된다

이름 앞부분이 어떤 유틸리티가 만들어질지를 결정한다.

네임스페이스생성되는 것
--color-*bg-*, text-*, border-*, fill-*, ring-*
--spacing-*p-*, m-*, gap-*, w-*, h-*
--font-*font-sans, font-serif
--text-*text-sm, text-xl (크기)
--radius-*rounded-*
--shadow-*shadow-*
--breakpoint-*sm:, md:, lg: 변형
--container-*@sm:, @md: 컨테이너 쿼리 변형
--animate-*animate-*

표의 숫자가 실제로 어떤 크기인지 — 기본 테마의 스케일을 실물로 렌더한 것이다.

p-0.5
2px
p-1
4px
p-2
8px
p-3
12px
p-4
16px
p-6
24px
p-8
32px
p-12
48px
p-16
64px
p-24
96px

숫자 1 = 4px. 스케일이 위로 갈수록 성기게 벌어진다 — 큰 간격일수록 후보가 적어야 고르기 쉽다.

v4의 기본 팔레트는 hex나 hsl이 아니라 oklch로 되어 있다.

  • 인지적 균일성 — oklch(0.5 ...)인 두 색은 사람 눈에 실제로 같은 밝기다
  • hsl은 그렇지 않다. hsl(60 100% 50%)(노랑)이 hsl(240 100% 50%)(파랑)보다 훨씬 밝다
  • 그래서 hsl로 만든 팔레트는 명도 계단이 들쭉날쭉해진다
  • oklch는 P3 같은 넓은 색역도 표현할 수 있다

기본 팔레트를 나란히 놓으면 그 균일함이 보인다 — 어느 계열이든 같은 단계는 같은 밝기라서, 500을 500으로 바꿔치기해도 대비가 유지된다.

zinc — 기본 중립색
50
100
200
300
400
500
600
700
800
900
950
indigo
50
100
200
300
400
500
600
700
800
900
950
emerald
50
100
200
300
400
500
600
700
800
900
950
rose
50
100
200
300
400
500
600
700
800
900
950

실무적 이득은 스케일을 프로그래밍으로 생성해도 자연스럽다는 것이다. L값만 일정하게 낮추면 균일한 팔레트가 나온다. 12장에서 다시 쓴다.

<button class="bg-zinc-900 hover:bg-zinc-700 focus-visible:ring-2
disabled:opacity-50 dark:bg-white dark:text-zinc-900
md:px-6 lg:px-8">
변형의미
hover: focus: active:상태 의사 클래스
focus-visible:키보드 포커스에만 (18장에서 중요해진다)
sm: md: lg: xl:최소 폭 (모바일 우선)
dark:다크 모드
group-hover:부모에 호버했을 때
peer-checked:형제가 체크됐을 때
data-[state=open]:임의의 data 속성
has-[:checked]:자식 조건 (CSS :has())
@md:컨테이너 크기 기준

md:grid-cols-2는 “md에서만”이 아니라 “md 이상에서”다.

<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">

이 한 줄이 각 브레이크포인트에서 확정되는 레이아웃 —

모바일~ 767px
1
2
3
4
5
6
grid-cols-1
태블릿md: 768px ~
1
2
3
4
5
6
md:grid-cols-2
데스크톱lg: 1024px ~
1
2
3
4
5
6
lg:grid-cols-3

기본값이 모바일이고, 브레이크포인트가 커질수록 덮어쓴다. sm: 없이 쓴 클래스가 모바일 스타일이라는 점을 놓치면 반응형이 거꾸로 짜인다.

부모/형제 상태에 반응하는 것을 JS 없이 한다.

<!-- group: 부모에 호버하면 자식이 반응한다 -->
<a class="group flex items-center gap-2 rounded-md p-3 hover:bg-zinc-100">
<span class="text-zinc-500 group-hover:text-zinc-900">아이콘</span>
<span class="group-hover:translate-x-1 transition">더보기</span>
</a>
<!-- peer: 앞의 형제 상태를 뒤의 형제가 읽는다 -->
<input type="checkbox" class="peer sr-only" id="agree" />
<label for="agree"
class="border peer-checked:border-indigo-500 peer-checked:bg-indigo-50">
동의합니다
</label>

data-[state=open]:rotate-180 같은 변형은 Base UI/Radix가 붙여주는 data 속성과 짝을 이룬다. shadcn/ui 컴포넌트가 이 패턴을 광범위하게 쓴다. (15장)

임의 값 — 스케일 밖으로 나가야 할 때

섹션 제목: “임의 값 — 스케일 밖으로 나가야 할 때”
<div class="top-[117px] grid-cols-[1fr_500px_2fr] bg-[#1da1f2]">
<div class="w-[calc(100%-2rem)] text-[color:var(--brand)]">
<div class="[mask-image:linear-gradient(to_bottom,black,transparent)]">
  • 대괄호 안에 아무 CSS 값이나 넣을 수 있다. 공백은 밑줄 _로
  • 마지막 형태는 임의 속성 — Tailwind에 유틸리티가 없는 CSS 속성도 쓸 수 있다
  • 하지만 자주 쓰면 냄새다. 반복된다면 @theme에 토큰으로 승격시킨다

좋은 신호는 임의 값이 한 번만 나타나는 것. 나쁜 신호는 bg-[#1da1f2]가 12군데에 흩어져 있는 것이다.

반드시 알아야 할 제약 — 클래스는 정적이어야 한다

섹션 제목: “반드시 알아야 할 제약 — 클래스는 정적이어야 한다”

Tailwind는 소스 파일을 텍스트로 스캔해서 필요한 CSS를 만든다. 그래서 문자열을 조립하면 찾지 못한다.

// ❌ 절대 동작하지 않는다 — 빌드 시 이런 문자열이 존재하지 않는다
<div className={`text-${color}-500`} />
<div className={`p-${size}`} />
// ✅ 완전한 클래스 이름을 나열한다
const colorClass = {
red: 'text-red-500',
blue: 'text-blue-500',
}[color]
// ✅ 또는 CSS 변수로 넘긴다 (동적 값이 진짜 필요할 때)
const barStyle = { '--bar-width': `${percent}%` } as React.CSSProperties
<div style={barStyle} className="w-[var(--bar-width)]" />

다크 모드와 자주 쓰는 유틸리티

섹션 제목: “다크 모드와 자주 쓰는 유틸리티”
/* v4에서는 다크 모드 전략도 CSS로 선언한다. 기본값은 prefers-color-scheme */
@custom-variant dark (&:where(.dark, .dark *));

직접 토글하게 하려면 위처럼 클래스 기반으로 바꾸고 next-themes를 쓴다. 다만 12장을 보고 나면 알게 되겠지만 — 토큰을 제대로 설계하면 dark: 변형을 거의 안 쓰게 된다.

유틸리티하는 일
space-y-4자식들 사이에만 세로 간격 (첫 요소 위엔 없음)
divide-y자식들 사이에 구분선
truncate한 줄 말줄임 (overflow+text-overflow+whitespace)
line-clamp-33줄 말줄임
sr-only화면엔 안 보이고 스크린리더에만 읽힘 (18장)
size-9w-9 h-9
inset-0top/right/bottom/left: 0
aspect-video16:9 비율 유지
field-sizing-content입력 내용에 맞춰 textarea 자동 크기
  • v4는 설정 파일이 사라지고 CSS의 @theme이 그 역할을 한다
  • @theme의 변수는 유틸리티 + CSS 변수를 동시에 만든다
  • 네임스페이스(--color-*, --spacing-*…)가 어떤 유틸리티가 생길지 결정한다
  • 색 공간은 oklch — 명도 계단이 균일해서 팔레트 생성이 쉽다
  • 변형은 hover: md: dark: group- peer- data-[] has-[] @md:
  • 클래스 이름은 반드시 정적이어야 한다. 문자열 조립은 동작하지 않는다
  • 임의 값 [...]은 탈출구지만, 반복되면 토큰으로 승격시킨다