CSS Variables as Bridge
이 문서가 답하는 질문: 두 컴파일러가 같은 값을 보게 하는 가장 단순한 방법은 무엇인가. 왜 CSS variables가 그 단일 출처여야 하는가. 한 줄 답 (Pyramid Top): CSS variables는 “두 컴파일러가 모두 emit한 후에도 살아남는 유일한 런타임 객체” 다.
tailwind.config와panda.config에 값을 박지 말고 둘 다var(--colors-primary-500)을 참조하게 만들면, 토큰 정의 한 줄만 바꾸어도 양쪽이 동시에 갱신된다.
Why — 왜 CSS variables가 브리지여야 하는가
두 컴파일러를 공존시킬 때 어디에 값을 박을 것인가는 세 가지 선택지가 있다.
| 선택지 | 어디에 값을 두나 | 문제 |
|---|---|---|
| 각 config에 hex 박기 | tailwind.config.colors.primary.500 = '#3b82f6'panda.config.tokens.colors.primary.500.value = '#3b82f6' | 디자이너가 색을 바꾸면 두 곳 수정. 동기화 실패. |
| 빌드 시점에 JSON에서 양쪽으로 inject | Style Dictionary로 두 config 파일 생성 | 좋은 출발. 하지만 런타임 다크모드가 어렵다. |
| CSS variables를 SSOT로 | :root { --colors-primary-500: oklch(...); } → 두 config는 그 변수를 참조만 | 디자이너가 색을 바꾸면 CSS 한 파일 수정. 런타임 테마 전환도 자연스러움. |
세 번째가 결정적인 이유는 CSS variables가 cascade에 살아있다는 점이다. JS에서 박은 hex는 컴파일 후에는 더 이상 변경 불가하지만, var(--colors-primary-500)은 <html data-theme="dark"> 같은 컨텍스트가 바뀔 때마다 재해석된다.
| 풀려는 문제 | 이전 해법 | CSS variables 해법 |
|---|---|---|
| 토큰 정의를 양쪽이 봐야 함 | 각 config에 중복 박기 | var(--token) 참조만 |
| 다크모드 즉시 전환 | class toggle + 두 가지 CSS 빌드 | :root[data-theme=dark] { --token: ... } 하나로 |
| 빌드타임 vs 런타임 | 둘 중 하나 포기 | CSS variables가 둘 다 만족 |
| 외부 SDK가 값을 알아야 함 | hardcode 또는 export | getComputedStyle(root).getPropertyValue('--token') |
How — CSS variables 브리지의 동작 원리
1) 브리지의 3층 구조
- tokens.css가 SSOT.
:root와:root[data-theme=dark]에 변수 정의. tailwind.config는colors.primary.500을var(...)로 참조만.panda.config도tokens.colors.primary.500.value로 같은 변수 참조.- 두 컴파일러가 emit한 CSS rule은 서로 다른 클래스 이름을 갖지만 동일한 변수를 가리킨다.
2) :root만으로는 부족한 이유 — 다크모드의 자연스러운 전환
/* tokens.css */
:root {
--colors-primary-500: oklch(0.65 0.18 250);
--colors-bg-surface: oklch(0.98 0.01 250);
--colors-text-default: oklch(0.20 0.02 250);
}
:root[data-theme="dark"] {
--colors-primary-500: oklch(0.70 0.18 250); /* 조금 밝게 */
--colors-bg-surface: oklch(0.18 0.02 250);
--colors-text-default: oklch(0.92 0.01 250);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--colors-primary-500: oklch(0.70 0.18 250);
/* ... */
}
}이 한 파일을 바꾸면:
- Tailwind의
.bg-primary-500도 다크에서 색이 바뀌고 - Panda의
.bg_primary_500도 다크에서 색이 바뀐다 - 두 도구의 다크모드 코드(
dark:bg-primary-600,_dark: { bg: 'primary.600' })를 둘 다 안 쓰는 길이 열림
What — 실제 통합 코드
1) tokens.css (단일 출처)
/* tokens.css — 양쪽 컴파일러가 참조할 SSOT */
@layer tokens {
:root {
/* Primitive — 색 스케일 */
--colors-blue-50: oklch(0.97 0.02 250);
--colors-blue-100: oklch(0.93 0.05 250);
--colors-blue-500: oklch(0.62 0.20 250);
--colors-blue-900: oklch(0.30 0.10 250);
--colors-gray-50: oklch(0.98 0.00 250);
--colors-gray-900: oklch(0.18 0.01 250);
/* Semantic — 의미 */
--colors-primary-500: var(--colors-blue-500);
--colors-bg-surface: var(--colors-gray-50);
--colors-text-default: var(--colors-gray-900);
/* Spacing (rem) */
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
--spacing-4: 1rem;
--spacing-8: 2rem;
/* Radius */
--radii-md: 0.5rem;
--radii-lg: 0.75rem;
}
:root[data-theme="dark"] {
--colors-primary-500: oklch(0.70 0.20 250);
--colors-bg-surface: var(--colors-gray-900);
--colors-text-default: oklch(0.92 0.01 250);
}
}2) tailwind.config.ts (Tailwind v3 패턴, v4 호환)
import type { Config } from 'tailwindcss'
export default {
content: [
'./app/**/*.{ts,tsx,mdx}',
'./styled-system/**/*.{ts,js}', // Panda codegen도 스캔 (중요)
],
theme: {
extend: {
colors: {
// hex 박지 말 것 — 모두 var() 참조
blue: {
50: 'var(--colors-blue-50)',
100: 'var(--colors-blue-100)',
500: 'var(--colors-blue-500)',
900: 'var(--colors-blue-900)',
},
gray: {
50: 'var(--colors-gray-50)',
900: 'var(--colors-gray-900)',
},
primary: {
500: 'var(--colors-primary-500)',
},
bg: {
surface: 'var(--colors-bg-surface)',
},
text: {
default: 'var(--colors-text-default)',
},
},
spacing: {
1: 'var(--spacing-1)',
2: 'var(--spacing-2)',
4: 'var(--spacing-4)',
8: 'var(--spacing-8)',
},
borderRadius: {
md: 'var(--radii-md)',
lg: 'var(--radii-lg)',
},
},
},
plugins: [],
} satisfies Config3) tailwind.config — v4의 CSS-first 방식
v4는 JS config 없이도 가능:
/* app.css (Tailwind v4) */
@import "tailwindcss";
@import "./tokens.css";
@theme {
--color-blue-500: var(--colors-blue-500);
--color-primary-500: var(--colors-primary-500);
--color-bg-surface: var(--colors-bg-surface);
--color-text-default: var(--colors-text-default);
--spacing-4: var(--spacing-4);
--radius-md: var(--radii-md);
}주의: Tailwind v4의 변수 prefix는 --color-*(단수)다. Panda는 --colors-*(복수)를 쓴다. 충돌을 피하려면 Panda 쪽 prefix를 명시하거나 SSOT의 이름을 미리 통일한다 (예: --ds-color-primary-500).
4) panda.config.ts
import { defineConfig } from '@pandacss/dev'
export default defineConfig({
preflight: false, // Tailwind preflight과 중복 방지 (07번 참고)
prefix: 'pd', // Panda 클래스에 prefix → .pd-bg_primary_500
include: ['./app/**/*.{ts,tsx}'],
outdir: 'styled-system',
theme: {
tokens: {
colors: {
blue: {
50: { value: 'var(--colors-blue-50)' },
100: { value: 'var(--colors-blue-100)' },
500: { value: 'var(--colors-blue-500)' },
900: { value: 'var(--colors-blue-900)' },
},
gray: {
50: { value: 'var(--colors-gray-50)' },
900: { value: 'var(--colors-gray-900)' },
},
},
spacing: {
1: { value: 'var(--spacing-1)' },
2: { value: 'var(--spacing-2)' },
4: { value: 'var(--spacing-4)' },
8: { value: 'var(--spacing-8)' },
},
radii: {
md: { value: 'var(--radii-md)' },
lg: { value: 'var(--radii-lg)' },
},
},
semanticTokens: {
colors: {
primary: {
500: { value: 'var(--colors-primary-500)' },
},
bg: {
surface: { value: 'var(--colors-bg-surface)' },
},
text: {
default: { value: 'var(--colors-text-default)' },
},
},
},
},
})핵심 관찰: Panda는 value가 어떤 문자열이든 받는다. CSS variable이든 hex든 alias든. Tailwind도 v3 기준 같은 자유도. 둘 다 문자열 그대로 emit하기에 var(...)이 자연스럽게 살아 흐른다.
5) PostCSS 파이프라인
// postcss.config.cjs
module.exports = {
plugins: {
'@pandacss/dev/postcss': {},
tailwindcss: {},
autoprefixer: {},
},
}또는 v4에서 @tailwindcss/postcss 사용:
module.exports = {
plugins: {
'@pandacss/dev/postcss': {},
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}순서가 중요: Panda → Tailwind 순으로 두면 Tailwind의 content 스캔이 Panda codegen 결과를 볼 수 있다. 자세한 cascade order는 07번 문서.
6) 실제 사용
// 두 스타일이 같은 화면에 살아있음
import { css } from 'styled-system/css'
export function Card() {
return (
<div className="bg-bg-surface text-text-default rounded-lg p-4">
{/* Tailwind utility */}
<h2 className="text-2xl font-bold mb-2">Card</h2>
<button
className={css({
bg: 'primary.500',
color: 'white',
px: 4, py: 2,
rounded: 'md',
_hover: { bg: 'primary.600' },
})}
>
Primary (Panda)
</button>
<button className="bg-primary-500 text-white px-4 py-2 rounded-md hover:bg-primary-600">
Primary (Tailwind)
</button>
</div>
)
}두 버튼이 동일한 색을 갖는다 — 둘 다 var(--colors-primary-500)을 본다.
What-if — 브리지가 깨지는 경우
- 함정 1: 변수 이름이 두 도구에서 다름 — Tailwind v4는 기본
--color-primary-500(단수), Panda는--colors-primary-500(복수). 한쪽 prefix를 통일하지 않으면 두 변수가 emit되어 SSOT가 둘이 됨 → 해결:tokens.css의 이름을 강제로 통일(--ds-color-*)하고 양 config가 같은 이름을 참조. - 함정 2: 다크모드 전환 시 한쪽만 반응 — Tailwind
dark:variant는.dark클래스를 기대, Panda_dark는data-theme=dark를 기대(설정에 따라). 둘 다 같은 selector를 보게 만들거나(darkMode: ['class', '[data-theme="dark"]']), CSS variables의 dark override만 사용하고dark:/_dark는 둘 다 안 쓴다. - 함정 3: 변수 정의가 너무 늦게 emit —
tokens.css가 Tailwind/Panda CSS 뒤에 import되면, 빌드는 OK여도 FOUC가 잠깐 보임. 해결: 가장 먼저 import. - 함정 4: 빌드 타임 hex 누락 — IDE 자동완성 깨짐 — Tailwind는 hex를 알아야 IntelliSense에서 색 미리보기를 보여줌.
var(...)만 있으면 회색 사각형만 표시. 해결: dev 모드에서는 hex, prod에서는 var() — 또는 그냥 감수. - 함정 5: CSS variables 미지원 환경 — IE11 (사실상 더 이상 쟁점 아님). 의미 있는 edge case는 embedded WebView (구형 안드로이드). 해결: 빌드 시점에 PostCSS
postcss-custom-propertiesplugin으로 fallback hex 주입.
Insight — 왜 모든 길이 CSS variables로 수렴하는가
2015년, CSS Custom Properties가 Chrome 49에 처음 들어왔을 때 반응은 미적지근했다. Sass 변수면 충분하지 않나? 라는 회의가 컸다. 하지만 두 가지 사건이 흐름을 바꾸었다.
- 2018, Mark Otto의 “Yes, you can have CSS variables in production” — 빌드 타임 변수(Sass)와 런타임 변수(CSS)의 차이가 다크모드와 멀티 브랜드에서 결정적이라는 점이 부각.
- 2021, W3C DTCG의 첫 draft — 플랫폼 중립적인 토큰 포맷이 표준화되면서 “CSS variable이 토큰의 런타임 표현”이라는 합의 형성.
오늘날의 모든 주요 도구가 CSS variables로 수렴한다:
- Tailwind v4:
@themedirective로 CSS variables를 1급으로 들이기 - Panda CSS: 처음부터 CSS variables 기반
- Open Props: CSS variables만으로 토큰 시스템 구성
- Radix Colors: 12-step scale을 CSS variables로 제공
- shadcn/ui: HSL을 CSS variable에 저장하고 Tailwind config에서 참조
이 수렴은 **“표준이 늦게 도착했지만 결국 도착했다”**의 좋은 예다. 변수의 정의는 한 곳, 소비는 여러 곳이라는 디자인 시스템의 본질을 CSS variables가 언어 차원에서 지원하게 된 결과다.
흥미로운 반전: shadcn/ui가 2023년에 Tailwind + CSS variables로 폭발적으로 성장한 것은, 본 문서의 패턴이 Tailwind 단독에서도 이미 옳다는 것을 보여준다. 즉 CSS variables는 두 도구의 공존을 위한 트릭이 아니라, 각 도구를 잘 쓰는 방법이기도 하다.
요약
- CSS variables는 두 컴파일러가 emit 후에도 살아남는 유일한 런타임 객체.
tokens.css에:root { --colors-primary-500: ... }를 두고 양쪽 config는 참조만.- 다크모드는
tokens.css의 override 하나로 양쪽이 동시에 반응 —dark:/_dark코드 제거 가능. - 변수 이름의 통일된 prefix 가 가장 흔한 함정 — 처음부터
--ds-color-*같은 규칙으로 못 박을 것.