콘텐츠로 이동

Tailwind v4 + shadcn/ui 도입 시 @theme inline 매핑 패턴과 자기참조 함정

맥락

React 19 + Vite + Tailwind v4 프로젝트에 shadcn/ui를 처음 도입할 때의 표준 패턴이다. shadcn CLI v4.2.0부터 init 명령이 preset 선택 방식으로 재설계되어 기존 --base-color slate 플래그가 제거됐고, 이전 shadcn v3 시절의 hsl(var(--primary)) 방식 문서가 섞여 있어 구버전 예시를 그대로 복사하면 오히려 동작하지 않는 경우가 많다.

참고: 이 프로젝트 실제 커밋에서는 브랜드 컬러 덮어쓰기를 시험 적용 후 제거했다. shadcn v4.2 공식 base color 목록에서 slate가 제외되면서 Neutral 시그니처로 회귀(c15d486). 덮어쓰기 패턴 자체는 "브랜드 톤 유지가 필수이고 공식 프리셋을 쓰지 않는 프로젝트"에 여전히 유효하다.

특히 다음 조합에서 발생한다: - @tailwindcss/vite 플러그인 방식 (Tailwind v4 권장) - src/index.css 단일 진입점 - 기존 CSS 변수 토큰이 :root에 이미 정의된 상태에서 shadcn 표준 변수로 전환

가이드

1. shadcn init 수동 수행 (v4.2.0 대응)

npx shadcn@latest init이 대화식 preset 선택으로 바뀌어 비인터랙티브 실행이 어렵다면, init이 수행하는 작업을 수동으로 재현한다. 결과물은 동일하다:

  1. components.json 생성 — style: "new-york", tailwind.baseColor: "slate", tailwind.cssVariables: true, tailwind.config: "" (Tailwind v4이므로 빈 문자열), aliases.components: "@/components", aliases.utils: "@/lib/utils".
  2. src/lib/utils.ts 생성 — 표준 cn() 헬퍼 (clsx + tailwind-merge).
  3. 의존성 추가 — clsx, tailwind-merge, class-variance-authority, lucide-react.
  4. src/index.css에 CSS 변수 + @theme inline 블록 작성.

이후 npx shadcn@latest add <component>components.json만 있으면 정상 동작한다.

2. :root 변수는 OKLCH로, @theme inlinevar() 직접 참조

shadcn 최신 + Tailwind v4의 표준은 다음 형식이다. hsl(var(--primary))로 감싸지 않는다.

@import "tailwindcss";

@custom-variant dark (&:is(.dark *));

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.141 0.005 285.823);
  --primary: oklch(0.546 0.245 262.881); /* 브랜드 컬러 #2563eb */
  --primary-foreground: oklch(0.985 0 0);
  --border: oklch(0.92 0.004 286.32);
  --ring: oklch(0.546 0.245 262.881);
  --radius: 0.5rem;
  /* ... shadcn 표준 변수 18개 ... */
}

.dark {
  --background: oklch(0.141 0.005 285.823);
  --foreground: oklch(0.985 0 0);
  /* ... 라이트와 동일한 변수명으로 다크 값 ... */
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-border: var(--border);
  --color-ring: var(--ring);
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
}

이 방식에서 bg-primary, text-muted-foreground, border-border 같은 Tailwind 유틸리티가 자동 생성된다. 구버전 hsl(var(--primary)) 래핑은 OKLCH 값에는 쓸 수 없고, Tailwind v4의 @theme inline은 값이 아니라 변수 참조를 기대한다.

3. @theme inline 내 동명 변수 자기참조 함정 (★ 핵심)

절대 이렇게 쓰지 말 것:

:root {
  --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
}
@theme inline {
  --shadow-card: var(--shadow-card); /* ❌ 순환 참조 */
}

@theme inline은 Tailwind 테마 변수를 CSS 커스텀 프로퍼티로 그대로 노출하기 때문에, :root--shadow-card@theme inline--shadow-card동일 CSS 변수로 병합되어 브라우저가 initial 값으로 해석한다. 결과적으로 shadow-card 유틸리티는 빈 값이 된다.

해결: :root 쪽 변수명에 -value 접미사

:root {
  --shadow-card-value: 0 1px 3px rgba(0, 0, 0, 0.06);
  --shadow-modal-value: 0 20px 48px rgba(0, 0, 0, 0.18);
}
.dark {
  --shadow-card-value: 0 1px 3px rgba(0, 0, 0, 0.3);
  --shadow-modal-value: 0 20px 48px rgba(0, 0, 0, 0.6);
}
@theme inline {
  --shadow-card: var(--shadow-card-value);   /* ✅ */
  --shadow-modal: var(--shadow-modal-value); /* ✅ */
}

같은 함정은 shadcn 표준 18개 색상 변수에는 발생하지 않는다. 그쪽은 --background(의미 변수)와 --color-background(Tailwind 토큰명)로 이름이 다르기 때문. 문제는 shadcn이 모르는 커스텀 확장 (shadow, app-bg 등 --color- 접두어가 없는 토큰명)을 추가할 때 발생한다.

4. 프로젝트 앱 배경색은 --background로 덮어쓰지 말고 별도 변수

shadcn --background는 카드·Dialog·Popover 등 컴포넌트 배경으로 사용되므로 흰색이어야 한다. 앱 전체 배경을 회색 톤으로 유지하려면 별도 커스텀 변수로 분리:

:root {
  --background: oklch(1 0 0);                /* shadcn 컴포넌트 배경 */
  --app-bg: oklch(0.968 0.007 247.896);      /* 앱 배경 (slate-100 근사) */
}
@theme inline {
  --color-app-bg: var(--app-bg);
}

body {
  background-color: var(--app-bg);
  color: var(--foreground);
}

:rootcolor, background-color, font-family 같은 typography 속성을 :root에 직접 두면 문법적으로는 동작하지만 비표준이다. body로 이동하는 것이 일관성이 있다.

5. @/* alias는 tsconfig refs 구조에서 수동 추가

tsconfig.json{ files: [], references: [...] }인 solution 파일이면 shadcn CLI가 paths를 자동 추가하는 데 실패할 수 있다. tsconfig.app.json에 직접 추가가 필수:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@/*": ["./src/*"] }
  }
}

루트 tsconfig.jsoncompilerOptions를 추가해도 참조 프로젝트에 상속되지 않는다 (TypeScript --build 모드의 공식 동작). 단, VS Code TypeScript Language Server의 인텔리센스 편의를 위해 루트에도 동일 설정을 중복으로 두는 것은 무해하다.

vite.config.ts에도 런타임 alias 필수:

import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))

export default defineConfig({
  plugins: [tailwindcss(), react(...)],
  resolve: { alias: { '@': path.resolve(__dirname, './src') } },
})

6. 다크모드 변수 정의와 토글 UI는 분리

shadcn init 직후 .dark { ... } 블록만 작성해두고, document.documentElement.classList 토글 UI는 별도 Phase에서 결정한다. 변수만 정의하면 빌드·렌더링에는 영향 없고, 이후 토글 기능 추가 시 CSS 수정 없이 완성된다.

왜 중요한가

  1. 구버전 예시를 복사하면 동작하지 않는다. shadcn v3의 hsl(var(--primary)) 래핑 방식이 Tailwind v4 + OKLCH 조합에서 대부분 예시로 돌아다니지만, 최신 공식 템플릿은 var() 직접 참조 방식이다. 구버전 복사는 빌드는 통과하지만 색상이 표시되지 않는 "조용한 실패"로 이어진다.

  2. 자기참조 함정은 빌드·린트에서 잡히지 않는다. 문법적으로 유효한 CSS이므로 빌드 통과. 브라우저 런타임에서 해당 유틸리티만 선택적으로 안 나오므로 발견이 어렵다. 도입 시점에 원칙(":root@theme inline의 변수명은 반드시 다르게")을 세워두는 것이 유일한 방어다.

  3. shadcn 표준 변수만 건드리다가 프로젝트 고유 토큰(success/warning/shadow)을 놓치기 쉽다. shadcn은 success·warning 색상을 표준에 포함하지 않는다. 커스텀 확장 시 위 자기참조 함정이 같이 튀어나온다.

적용 시기

  • 적용: React + Vite + Tailwind v4 + shadcn/ui 첫 도입 시 (2026 이후 기준).
  • 적용: 기존 프로젝트의 CSS 변수 토큰을 shadcn 표준으로 마이그레이션할 때.
  • 적용: 다크모드 1급 지원이 필요한 프로젝트 (shadcn의 CSS 변수 방식이 기본).
  • 비적용: Next.js 13+ App Router에서 globals.css + Server Components 조합 (components.json의 rsc: true 필요 — 이 문서는 rsc: false SPA 기준).
  • 비적용: Tailwind v3 프로젝트 — @theme inline이 없고 tailwind.config.jstheme.extend.colors로 설정해야 한다. 문법이 완전히 다르다.

관련 자료