shadcn/ui 다크 모드 + 멀티 브랜드 테마를 CSS 변수 하나로 관리하는 법
다크 모드 버그 티켓이 열 개 쌓여 있는데, 고쳐야 할 컴포넌트가 200개라면? 저도 정확히 그 상황을 겪었습니다. dark: 클래스를 컴포넌트마다 달다 보면, 색상 하나 바꾸는 데 파일을 수십 개 열어야 하는 유지보수 지옥이 시작됩니다.
그 경험 이후로 방향을 바꿨습니다. CSS 커스텀 프로퍼티(CSS 변수)를 디자인 토큰으로 활용하면, 컴포넌트 코드를 단 한 줄도 건드리지 않고 다크 모드와 멀티 브랜드 테마를 동시에 지원할 수 있습니다. shadcn/ui가 정확히 이 구조로 설계되어 있고, Tailwind v4의 @theme inline이 이를 더 깔끔하게 만들어줍니다.
이 글은 Next.js + Tailwind를 사용 중인 개발자를 대상으로 합니다. shadcn/ui를 막 도입하는 경우라도 괜찮습니다. 읽고 나면 브랜드 테마를 새로 추가할 때 CSS 몇 줄 외에는 아무것도 수정하지 않아도 되는 구조를 직접 만들 수 있게 됩니다.
이 글의 환경 Next.js 14+, shadcn/ui (Tailwind v4 기반), next-themes
이 방식이 맞지 않는 경우: 구형 브라우저(Chrome 111, Safari 15.4 미만) 지원이 필수인 프로젝트라면, OKLCH를 사용하는 shadcn/ui 기본 토큰 값에 HSL fallback을 병행 선언하는 추가 작업이 필요합니다.
핵심 개념
토큰의 두 레이어: Primitive와 Semantic
이 두 개념을 구분하는 게 이 시스템 전체의 핵심입니다. 처음에 뭉뚱그려 이해했다가 토큰이 수십 개로 불어났을 때 명명 규칙이 무너지는 걸 직접 겪었거든요.
| 레이어 | 역할 | 예시 |
|---|---|---|
| Primitive(원시) 토큰 | 실제 색상값 그 자체 | oklch(0.55 0.22 265) |
| Semantic(의미론적) 토큰 | Primitive에 역할을 부여 | --primary, --background, --foreground |
shadcn/ui 컴포넌트는 Semantic 토큰만 참조합니다. --primary가 가리키는 실제 색상값이 뭐든 컴포넌트는 신경 쓰지 않습니다. 그래서 테마를 바꿀 때 컴포넌트를 건드릴 필요가 없는 겁니다.
코드에서도 이 두 레이어를 명확히 분리해두는 게 좋습니다. 한 블록에 섞이면 나중에 어디서 어디로 참조하는지 추적하기 힘들어지거든요.
:root {
/* Primitive — 실제 색상값 */
--color-slate-950: oklch(0.145 0 0);
--color-white: oklch(1 0 0);
/* Semantic — Primitive를 역할에 매핑 */
--background: var(--color-white);
--foreground: var(--color-slate-950);
--primary: var(--color-slate-950);
--primary-foreground: var(--color-white);
--card: var(--color-white);
--card-foreground: var(--color-slate-950);
--border: oklch(0.922 0 0);
--radius: 0.625rem;
}
/* 다크 모드: 같은 Semantic 토큰 이름, 다른 값 */
.dark {
--background: var(--color-slate-950);
--foreground: var(--color-white);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--border: oklch(0.269 0 0);
}.dark 클래스 하나로 모든 컴포넌트에 다크 모드가 전파됩니다.
CSS Cascade란? 상위 선택자에서 같은 변수 이름을 재정의하면 하위 모든 요소에 덮어씌워지는 CSS 규칙입니다.
:root에서 선언한--background를.dark블록에서 재정의하면,.dark클래스가 붙은 엘리먼트 하위의 모든 요소에 새 값이 자동 전파됩니다.
선택자 충돌 주의사항: .dark와 html[data-theme]을 혼용할 때는 선택자 우선순위가 의도치 않게 충돌할 수 있습니다. :root와 html 선택자는 동일 특이도(specificity)이므로 선언 순서가 중요합니다. @theme inline → :root → .dark → html[data-theme] 순서를 지켜두면 Cascade가 의도대로 동작합니다.
OKLCH 색상 공간: 왜 HSL에서 바꿨는가
솔직히 처음에는 "그냥 HSL 쓰면 안 되나?" 싶었습니다. 근데 다크 모드를 세밀하게 다듬다 보면 HSL의 단점이 드러납니다. 같은 채도(Saturation) 값이어도 색조(Hue)에 따라 실제 체감 밝기가 달라져서, 노란색과 파란색을 같은 명도로 맞추려면 값을 일일이 보정해야 했거든요.
/* HSL: 같은 채도(100%)지만 체감 밝기가 색조마다 다름 */
hsl(60 100% 50%) /* 노랑 — 눈이 아플 만큼 밝음 */
hsl(240 100% 50%) /* 파랑 — 상대적으로 어둡게 보임 */
/* OKLCH: L 값이 실제 밝기와 일치 */
oklch(0.96 0.21 109) /* 노랑 계열 */
oklch(0.45 0.31 264) /* 파랑 계열 — L만 봐도 밝기 차이가 명확 */OKLCH는 인간 시각에 맞게 보정된 색상 공간이라 동일한 L(밝기) 값이 실제로도 비슷한 밝기로 보입니다. 다크 모드에서 색상 채도가 일관되게 유지되는 이유입니다.
OKLCH 는
oklch(밝기 채도 색조)형태입니다. 밝기는 0(검정)1(흰색), 채도는 00.4 정도, 색조는 0~360도입니다. Chrome 111+, Safari 15.4+에서 지원됩니다.
Tailwind v4의 @theme inline: 설정 파일의 종말
Tailwind v4 이전에는 tailwind.config.js에서 색상 팔레트를 정의했습니다. CSS 변수와 Tailwind 설정, 두 군데를 동기화해야 했죠. Tailwind v4는 이걸 CSS로 통합했습니다.
여기서 @theme과 @theme inline의 차이를 짚고 넘어가는 게 중요합니다. 이걸 모르고 쓰다가 CSS 변수가 두 벌 생성되는 황당한 상황을 맞닥뜨린 경험이 있거든요.
@theme: Tailwind가 새 CSS 변수를 직접 생성하면서 유틸리티 클래스에 바인딩합니다.@theme inline: 기존에 선언된 CSS 변수를 참조만 합니다. 중복 변수를 만들지 않습니다.
:root에서 이미 --primary 같은 변수를 선언해뒀다면, @theme을 쓸 경우 --color-primary라는 변수가 하나 더 생겨 어느 쪽이 우선인지 혼선이 생깁니다. @theme inline이 이 중복을 막아줍니다.
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--radius-lg: var(--radius);
}이제 bg-primary, text-foreground, border-border, rounded-lg 같은 Tailwind 유틸리티 클래스가 CSS 변수를 자동으로 반영합니다. tailwind.config.js는 더 이상 필요 없습니다.
실전 적용
개념을 코드로 연결해볼 차례입니다. 네 가지 예시가 하나의 프로젝트 안에서 조합되는 흐름으로 이어집니다.
예시 1: globals.css 전체 구조 — 토큰을 한 곳에 모으기
파편화된 스니펫을 조합할 때 순서를 틀리면 Cascade가 의도대로 동작하지 않습니다. 완성된 파일 구조를 한 번에 보는 게 가장 빠른 방법입니다.
/* globals.css */
@import "tailwindcss";
/* 1. Tailwind v4 테마 바인딩 — 반드시 가장 앞에 위치 */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--radius-lg: var(--radius);
}
/* 2. 기본 라이트 테마 */
:root {
/* Primitive */
--color-slate-950: oklch(0.145 0 0);
--color-white: oklch(1 0 0);
/* Semantic → Primitive 참조 */
--background: var(--color-white);
--foreground: var(--color-slate-950);
--primary: var(--color-slate-950);
--primary-foreground: var(--color-white);
--card: var(--color-white);
--card-foreground: var(--color-slate-950);
--border: oklch(0.922 0 0);
--radius: 0.625rem;
}
/* 3. 기본 다크 테마 */
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--card: oklch(0.205 0 0); /* background보다 3~5% 밝게 해야 깊이감이 생깁니다 */
--card-foreground: oklch(0.985 0 0);
--border: oklch(0.269 0 0);
}
/* 4. 브랜드 테마 — 필요한 경우 이 아래에 추가 */
html[data-theme='brand-a'] {
--background: oklch(0.98 0.005 280);
--foreground: oklch(0.15 0.01 280);
--primary: oklch(0.55 0.22 285);
--primary-foreground: oklch(1 0 0);
--card: oklch(0.96 0.008 280);
--border: oklch(0.88 0.015 280);
}@theme inline → :root → .dark → html[data-theme] 순서를 지켜두면 선택자 충돌 없이 Cascade가 의도대로 동작합니다.
예시 2: next-themes로 다크 모드 전환 연결
CSS 변수를 정의했으니, 이제 Next.js에서 실제로 전환하는 코드를 연결해볼 차례입니다. next-themes의 ThemeProvider가 html 엘리먼트에 .dark 클래스를 토글해주고, CSS Cascade가 나머지를 처리합니다.
// app/providers.tsx
import { ThemeProvider } from 'next-themes'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
)
}| 설정 | 의미 |
|---|---|
attribute="class" |
html.dark 형태로 클래스를 토글 |
defaultTheme="system" |
OS 다크 모드 설정을 기본값으로 |
enableSystem |
prefers-color-scheme 미디어 쿼리 감지 활성화 |
disableTransitionOnChange |
테마 전환 시 깜박임 방지 |
앞서 CSS 변수를 연결했으니, 이어서 토글 버튼을 만들 차례입니다. 여기서 실수하기 쉬운 지점이 하나 있습니다. theme 값을 mounted 체크 없이 렌더링하면 서버/클라이언트 hydration 불일치 경고가 발생합니다.
// components/theme-toggle.tsx
'use client'
import { useState, useEffect } from 'react'
import { useTheme } from 'next-themes'
export function ThemeToggle() {
const [mounted, setMounted] = useState(false)
const { theme, setTheme } = useTheme()
useEffect(() => setMounted(true), [])
// 마운트 전에는 렌더링하지 않아야 hydration 불일치를 피할 수 있습니다
if (!mounted) return null
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground"
>
{theme === 'dark' ? '라이트 모드' : '다크 모드'}
</button>
)
}예시 3: 멀티 브랜드 테마 구성
다크 모드 전환이 연결됐으니, 이 구조를 멀티 브랜드로 확장해볼 차례입니다. SaaS 제품에서 고객사별 브랜드 색상을 지원해야 하는 상황이 생각보다 자주 옵니다. 이럴 때 data-theme 속성으로 여러 토큰 세트를 정의하는 패턴이 표준으로 자리잡았습니다.
/* globals.css의 브랜드 테마 섹션 */
/* 브랜드 A — 보라 계열 */
html[data-theme='brand-a'] {
--background: oklch(0.98 0.005 280);
--foreground: oklch(0.15 0.01 280);
--primary: oklch(0.55 0.22 285);
--primary-foreground: oklch(1 0 0);
--card: oklch(0.96 0.008 280);
--border: oklch(0.88 0.015 280);
}
/* 브랜드 A 다크 */
html[data-theme='brand-a-dark'] {
--background: oklch(0.15 0.01 285);
--foreground: oklch(0.95 0.005 280);
--primary: oklch(0.72 0.18 285);
--primary-foreground: oklch(0.1 0 0);
--card: oklch(0.2 0.015 285); /* background보다 살짝 밝게 — 계층감이 살아납니다 */
--border: oklch(0.32 0.02 285);
}// app/providers.tsx — 멀티 테마 버전
import { ThemeProvider } from 'next-themes'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="data-theme"
themes={['light', 'dark', 'brand-a', 'brand-a-dark', 'brand-b', 'brand-b-dark']}
defaultTheme="light"
>
{children}
</ThemeProvider>
)
}attribute="data-theme"으로 바꾸면 html[data-theme='brand-a'] 선택자가 동작합니다.
한 가지 제약사항을 미리 알아두면 좋습니다: brand-a와 brand-a-dark를 별도 테마 이름으로 분리하는 구조는 enableSystem 옵션과 충돌합니다. OS 다크 모드가 활성화되어도 brand-a-dark로 자동 전환되지 않습니다. 시스템 다크 모드 감지와 브랜드 테마를 동시에 지원하려면 미디어 쿼리 기반 별도 로직이 필요합니다. 구조를 결정하기 전에 팀과 미리 합의해두는 것을 권장합니다.
화이트라벨(White-label) 이란 하나의 제품을 여러 브랜드에서 자신들의 로고와 색상을 입혀 자체 제품처럼 제공하는 방식입니다. 토큰 기반 시스템에서는 컴포넌트를 공유하고 CSS 변수 세트만 고객사별로 교체하면 됩니다.
예시 4: 토큰만 사용하는 컴포넌트 작성
앞서 정의한 토큰이 실제로 어떻게 활용되는지 볼 차례입니다. 이게 이 시스템이 작동하는 이유이기도 합니다. 컴포넌트 내부에 bg-white, text-gray-900 같은 하드코딩 색상이 들어가는 순간, 다크 모드 대응이 그 파일에서만 무너집니다.
// ❌ 하드코딩 — 다크 모드에서 흰 배경이 그대로 남음
function Card({ children }: { children: React.ReactNode }) {
return (
<div className="bg-white text-gray-900 border border-gray-200 rounded-lg p-6">
{children}
</div>
)
}
// ✅ 토큰 클래스만 사용 — 어떤 테마에서도 올바르게 동작
function Card({ children }: { children: React.ReactNode }) {
return (
<div className="bg-card text-card-foreground border border-border rounded-lg p-6 shadow-sm">
{children}
</div>
)
}// 버튼도 같은 원칙
function PrimaryButton({ children, onClick }: {
children: React.ReactNode
onClick?: () => void
}) {
return (
<button
onClick={onClick}
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
>
{children}
</button>
)
}bg-primary/90처럼 슬래시 뒤에 숫자를 붙이면 CSS 변수 기반 색상에도 투명도를 적용할 수 있습니다. 이 문법은 Tailwind v3.1부터 지원됐고, v4에서는 CSS 변수 기반 색상에서도 더 안정적으로 동작합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 컴포넌트 불변성 | 토큰 값만 바꾸면 전체 UI가 바뀜. 컴포넌트 코드 수정 불필요 |
| 다크 모드 자동 전파 | .dark 선택자 하나로 모든 컴포넌트에 적용 |
| 멀티 브랜드 확장성 | 토큰 세트 하나만 추가하면 화이트라벨·테넌트 테마 즉시 지원 |
| Figma 연동 | Figma Variables가 CSS 변수와 동일한 계층 구조로 관리 가능 |
| 접근성 검증 단위 | 시맨틱 토큰 단위로 명도 대비(contrast ratio)를 일괄 검증 가능 |
| 단일 출처 | Tailwind v4에서 CSS 파일이 유일한 토큰 원본. 설정 파일 이중 관리 불필요 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| OKLCH 브라우저 지원 | Chrome 111+, Safari 15.4+ 이상 필요 | 구형 지원이 필요하면 HSL fallback을 병행 선언 |
| 멀티 브랜드 + 시스템 다크 모드 | brand-a-dark 분리 구조에서 enableSystem 자동 전환 안 됨 |
미디어 쿼리 기반 별도 전환 로직 추가 필요 |
| 토큰 명세 복잡도 | 토큰 수가 많아질수록 관리가 어려워짐 | 명명 규칙 가이드라인을 팀 내에서 문서화하는 것을 권장 |
| CLI 업데이트 충돌 | shadcn/ui CLI로 컴포넌트 업데이트 시 커스텀 수정이 덮어씌워질 수 있음 | 래퍼 컴포넌트 패턴으로 원본 컴포넌트를 감싸는 것이 좋습니다 |
| 카드/팝오버 계층 | 다크 모드에서 깊이감이 사라지기 쉬움 | --card와 --popover를 --background보다 밝기 3~5% 높게 설정. 카드 배경을 3% 올리는 것만으로도 계층감이 살아납니다 |
| border 시각적 노이즈 | 너무 밝은 --border는 다크 모드에서 과도하게 눈에 띔 |
주변 배경 대비 채도를 낮게, 밝기를 조금만 높이는 정도로 조정 |
실무에서 가장 흔한 실수
bg-white,text-black같은 하드코딩 색상 클래스를 컴포넌트 내부에 직접 사용하는 것 — 다크 모드가 일부 컴포넌트에서만 동작하지 않는 원인이 됩니다.--background를oklch(0 0 0)순수 검정으로 설정하는 것 — 너무 강렬해서 오래 보기 어렵습니다. 밝기를 4~10% 범위로 설정하는 게 눈에 편합니다.@theme inline없이 Tailwind v4를 사용하면서 "bg-primary가 왜 안 먹히지?" 하는 상황 — CSS 변수와 Tailwind 유틸리티 클래스를 연결하는@theme inline블록이 반드시 있어야 합니다.ThemeToggle에서mounted체크 없이theme값을 렌더링하는 것 — 서버/클라이언트 hydration 불일치 경고가 발생합니다.
마치며
CSS 변수 두 레이어(Primitive → Semantic)와 @theme inline 바인딩을 이해하면, shadcn/ui 컴포넌트를 단 한 줄도 수정하지 않고 다크 모드와 멀티 브랜드 테마를 동시에 지원하는 시스템을 구성할 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
npx shadcn@latest init으로 shadcn/ui를 설치하고, 자동 생성된globals.css의:root와.dark블록을 DevTools로 추적해보시면 좋습니다.- tweakcn.com에서 브랜드 색상을 입력해 OKLCH 토큰 값을 생성하고, 내보낸 CSS를
globals.css에 붙여넣어보시면 좋습니다. ThemeProvider를attribute="data-theme"으로 설정해 브랜드 테마 전환을 직접 테스트해보시면 이 시스템의 가치가 바로 체감됩니다.
참고 자료
- shadcn/ui Theming 공식 문서 — 토큰 변수 목록과 기본 구조를 파악할 때 가장 먼저 볼 문서
- shadcn/ui Tailwind v4 마이그레이션 가이드 — v3에서 v4로 전환할 때 바뀐 항목을 체크리스트처럼 활용 가능
- shadcn/ui Dark Mode 공식 문서 — next-themes 연동 설정의 레퍼런스
- Tailwind v4 지원 공식 Changelog (2025-02) —
@theme inline도입 배경과 변경 사항 요약 - Theming Shadcn with Tailwind v4 and CSS Variables — Medium — 이 글과 비슷한 흐름을 영어로 더 자세히 설명한 글
- Building a Scalable Design System with Shadcn/UI, Tailwind CSS, and Design Tokens — Medium — 토큰 계층 설계를 더 깊게 파고들고 싶을 때
- Multi-Theme Magic: Next.js + shadcn/ui — Vaibhav Tyagi — 멀티 테마 패턴의 실전 구현 사례
- tweakcn — 인터랙티브 테마 에디터 — OKLCH 값을 시각적으로 조정하며 토큰을 바로 내보낼 수 있는 도구. 색상 방향이 잡히면 여기서 시작하는 것을 권장
- Shadcn Studio — AI 테마 생성기 — 색상 방향이 잡히지 않을 때 AI로 초안을 빠르게 잡고 싶다면