🧩 Design System7. Panda × Tailwind 호환CSS Variables as Bridge — 양쪽 컴파일러가 같은 변수를 보게 한다

CSS Variables as Bridge

이 문서가 답하는 질문: 두 컴파일러가 같은 값을 보게 하는 가장 단순한 방법은 무엇인가. 왜 CSS variables가 그 단일 출처여야 하는가. 한 줄 답 (Pyramid Top): CSS variables는 “두 컴파일러가 모두 emit한 후에도 살아남는 유일한 런타임 객체” 다. tailwind.configpanda.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에서 양쪽으로 injectStyle 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 또는 exportgetComputedStyle(root).getPropertyValue('--token')

How — CSS variables 브리지의 동작 원리

1) 브리지의 3층 구조

  1. tokens.css가 SSOT. :root:root[data-theme=dark]에 변수 정의.
  2. tailwind.configcolors.primary.500var(...)참조만.
  3. panda.configtokens.colors.primary.500.value같은 변수 참조.
  4. 두 컴파일러가 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 Config

3) 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 _darkdata-theme=dark를 기대(설정에 따라). 둘 다 같은 selector를 보게 만들거나(darkMode: ['class', '[data-theme="dark"]']), CSS variables의 dark override만 사용하고 dark: / _dark둘 다 안 쓴다.
  • 함정 3: 변수 정의가 너무 늦게 emittokens.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-properties plugin으로 fallback hex 주입.

Insight — 왜 모든 길이 CSS variables로 수렴하는가

2015년, CSS Custom Properties가 Chrome 49에 처음 들어왔을 때 반응은 미적지근했다. Sass 변수면 충분하지 않나? 라는 회의가 컸다. 하지만 두 가지 사건이 흐름을 바꾸었다.

  1. 2018, Mark Otto의 “Yes, you can have CSS variables in production” — 빌드 타임 변수(Sass)와 런타임 변수(CSS)의 차이가 다크모드와 멀티 브랜드에서 결정적이라는 점이 부각.
  2. 2021, W3C DTCG의 첫 draft플랫폼 중립적인 토큰 포맷이 표준화되면서 “CSS variable이 토큰의 런타임 표현”이라는 합의 형성.

오늘날의 모든 주요 도구가 CSS variables로 수렴한다:

  • Tailwind v4: @theme directive로 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-* 같은 규칙으로 못 박을 것.