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이 수행하는 작업을 수동으로 재현한다. 결과물은 동일하다:
components.json생성 —style: "new-york",tailwind.baseColor: "slate",tailwind.cssVariables: true,tailwind.config: ""(Tailwind v4이므로 빈 문자열),aliases.components: "@/components",aliases.utils: "@/lib/utils".src/lib/utils.ts생성 — 표준cn()헬퍼 (clsx+tailwind-merge).- 의존성 추가 —
clsx,tailwind-merge,class-variance-authority,lucide-react. src/index.css에 CSS 변수 +@theme inline블록 작성.
이후 npx shadcn@latest add <component>는 components.json만 있으면 정상 동작한다.
2. :root 변수는 OKLCH로, @theme inline은 var() 직접 참조¶
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);
}
:root의 color, background-color, font-family 같은 typography 속성을 :root에 직접 두면 문법적으로는 동작하지만 비표준이다. body로 이동하는 것이 일관성이 있다.
5. @/* alias는 tsconfig refs 구조에서 수동 추가¶
tsconfig.json이 { files: [], references: [...] }인 solution 파일이면 shadcn CLI가 paths를 자동 추가하는 데 실패할 수 있다. tsconfig.app.json에 직접 추가가 필수:
루트 tsconfig.json에 compilerOptions를 추가해도 참조 프로젝트에 상속되지 않는다 (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 수정 없이 완성된다.
왜 중요한가¶
-
구버전 예시를 복사하면 동작하지 않는다. shadcn v3의
hsl(var(--primary))래핑 방식이 Tailwind v4 + OKLCH 조합에서 대부분 예시로 돌아다니지만, 최신 공식 템플릿은var()직접 참조 방식이다. 구버전 복사는 빌드는 통과하지만 색상이 표시되지 않는 "조용한 실패"로 이어진다. -
자기참조 함정은 빌드·린트에서 잡히지 않는다. 문법적으로 유효한 CSS이므로 빌드 통과. 브라우저 런타임에서 해당 유틸리티만 선택적으로 안 나오므로 발견이 어렵다. 도입 시점에 원칙("
:root와@theme inline의 변수명은 반드시 다르게")을 세워두는 것이 유일한 방어다. -
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: falseSPA 기준). - 비적용: Tailwind v3 프로젝트 —
@theme inline이 없고tailwind.config.js의theme.extend.colors로 설정해야 한다. 문법이 완전히 다르다.
관련 자료¶
- 실제 도입 커밋: AssetManagement/Frontend
c19784d(Phase 9-A) - 후속 보정(primary 시그니처 복원): AssetManagement/Frontend
c15d486 - 관련 ADR: ADR-007
- 작업지시서: Planning/작업_지시서_Phase9A_shadcn_초기설정