🧩 Design System6. Theming (다크·브랜드·a11y)05 — Panda · Tailwind Theme Config

05 — Panda · Tailwind Theme Config

이 문서가 답하는 질문: Panda CSS의 conditions API, Tailwind v3의 darkMode: 'class', Tailwind v4의 @variant@theme — 셋이 같은 selector([data-theme="dark"])로 수렴하도록 설정하려면 정확히 무엇을 써야 하는가? 그리고 둘 다 한 프로젝트에 공존시킬 때 충돌이 없는가? 한 줄 답 (Pyramid Top): 정답은 [data-theme="dark"] selector를 유일한 진실로 정하고, Panda는 conditions: { dark: '[data-theme=dark] &' }, Tailwind v3는 darkMode: ['selector', '[data-theme="dark"]'], Tailwind v4는 @variant dark 재정의” — 세 시스템이 같은 selector를 보면 컴포넌트 한 벌이 양쪽에서 동시에 작동한다. Panda의 conditions임의 확장이 가능해 brand, contrast, locale도 같은 메커니즘으로.


Why — 왜 존재하는가

한 프로젝트에 두 라이브러리가 공존하는 경우는 점점 흔해진다 — 레거시는 Tailwind, 신규 컴포넌트는 Panda. 다크모드 토글이 한 라이브러리에서만 작동하면 페이지의 절반이 라이트로 남는다.

풀려는 문제단순 해법한계
Tailwind + Panda 공존각자 다른 selector페이지 반쪽만 다크
사용자 토글 신호 분기두 곳에서 토글 처리동기화 버그
brand 추가둘 다 따로 설정일관성 붕괴
custom condition (e.g. _jiraDark)Panda만 지원 (Tailwind v3는 어려움)차원 어긋남

Panda의 conditions임의 selector를 condition 키로 등록할 수 있다 — _dark, _jira, _highContrast 등. Tailwind v4의 @variant도 v3 대비 비슷한 유연성을 갖췄다. 두 시스템의 condition 정의를 같은 selector로 정렬하는 것이 핵심.


How — 어떻게 동작하는가


What — 구체 사양 / 수치 / 예시

1) Panda CSS — conditions 정의

// panda.config.ts
import { defineConfig } from '@pandacss/dev';
 
export default defineConfig({
  // selectors라는 이름의 1급 시민
  conditions: {
    // 기본 다크 — next-themes 호환
    dark: '[data-theme=dark] &',
    light: '[data-theme=light] &',
 
    // 시스템 추적
    osDark: '@media (prefers-color-scheme: dark)',
    osLight: '@media (prefers-color-scheme: light)',
 
    // 브랜드 (멀티 브랜드 챕터 참조)
    jira: '[data-brand=jira] &',
    confluence: '[data-brand=confluence] &',
 
    // 직교 결합 — brand × theme
    jiraDark: '[data-brand=jira][data-theme=dark] &',
 
    // 접근성
    highContrast: '@media (prefers-contrast: more)',
    reducedMotion: '@media (prefers-reduced-motion: reduce)',
    forcedColors: '@media (forced-colors: active)',
  },
 
  theme: {
    semanticTokens: {
      colors: {
        'bg.surface': {
          value: {
            base: '{colors.gray.50}',
            _dark: '{colors.gray.900}',
            _highContrast: '{colors.white}',
          },
        },
        'bg.accent': {
          value: {
            base: '{colors.blue.500}',
            _dark: '{colors.blue.400}',
            _jira: '{colors.blue.600}',
            _jiraDark: '{colors.blue.300}',
          },
        },
      },
    },
  },
});

사용:

import { css } from 'styled-system/css';
 
<button className={css({
  bg: 'bg.accent',          // 모든 컨텍스트에서 자동
  color: 'fg.on.accent',
  _hover: { bg: 'bg.accent.hover' },
  _reducedMotion: { transition: 'none' },
})}>
  Click
</button>

Panda는 컴파일 시 모든 condition 조합을 CSS variable swap으로 컴파일.

2) Tailwind v3 — darkMode: 'selector' (3.4.1+)

// tailwind.config.js
module.exports = {
  // 두 selector 모두 매치 — next-themes 호환
  darkMode: ['selector', '[data-theme="dark"]'],
 
  content: ['./src/**/*.{tsx,html}'],
 
  theme: {
    extend: {
      // semantic token을 CSS variables로 정의
      colors: {
        'bg-surface': 'var(--color-bg-surface)',
        'bg-accent': 'var(--color-bg-accent)',
        'fg-default': 'var(--color-fg-default)',
        'fg-on-accent': 'var(--color-fg-on-accent)',
      },
    },
  },
 
  plugins: [
    // 커스텀 variant 추가 (v3에서 brand 등)
    function ({ addVariant }) {
      addVariant('jira', '[data-brand="jira"] &');
      addVariant('confluence', '[data-brand="confluence"] &');
      addVariant('high-contrast', '@media (prefers-contrast: more)');
    },
  ],
};
/* globals.css — CSS variables를 직접 정의 */
:root {
  --color-bg-surface: #fafafa;
  --color-bg-accent: #3370b8;
}
 
[data-theme="dark"] {
  --color-bg-surface: #18181b;
  --color-bg-accent: #4a8cd4;
}
 
[data-brand="jira"] {
  --color-bg-accent: #0052cc;
}

마크업 — clean, no dark: prefix:

<button class="bg-bg-accent text-fg-on-accent">Click</button>

Tailwind v3의 전통 패턴(bg-white dark:bg-gray-900)을 굳이 안 따른다. 대신 semantic token을 Tailwind utility 이름으로 등록.

3) Tailwind v4 — @theme + @variant

/* app.css */
@import "tailwindcss";
 
/* dark variant를 [data-theme=dark]로 재정의 */
@variant dark (&:where([data-theme=dark], [data-theme=dark] *));
 
/* brand variant 신규 정의 */
@variant jira (&:where([data-brand=jira] *));
@variant confluence (&:where([data-brand=confluence] *));
@variant high-contrast (@media (prefers-contrast: more));
 
@theme {
  /* primitive */
  --color-blue-500: #3370b8;
  --color-blue-400: #4a8cd4;
  --color-gray-50: #fafafa;
  --color-gray-900: #18181b;
 
  /* semantic — light 기본 */
  --color-bg-surface: var(--color-gray-50);
  --color-bg-accent: var(--color-blue-500);
}
 
@layer base {
  [data-theme="dark"] {
    --color-bg-surface: var(--color-gray-900);
    --color-bg-accent: var(--color-blue-400);
  }
  [data-brand="jira"] {
    --color-bg-accent: #0052cc;
  }
}

이제 bg-bg-surface 같은 semantic-named utility가 모든 컨텍스트에서 자동.

4) Panda + Tailwind 동시 사용 — 같은 selector

07-panda-tailwind-interop의 핵심 주제지만, 테마 측면에서 정렬할 것:

시스템dark selectorbrand selector
HTML<html data-theme="dark"><html data-brand="jira">
Panda_dark condition_jira condition
Tailwind v3darkMode: ['selector', '[data-theme="dark"]']addVariant('jira', '[data-brand=jira] &')
Tailwind v4@variant dark (...)@variant jira (...)

같은 HTML attribute(data-theme, data-brand)를 보면 세 시스템이 함께 작동.

5) condition 우선순위 — 직교 결합이 중요한 이유

// panda.config.ts
conditions: {
  dark: '[data-theme=dark] &',
  jira: '[data-brand=jira] &',
  jiraDark: '[data-brand=jira][data-theme=dark] &',
},
 
theme: {
  semanticTokens: {
    colors: {
      'bg.accent': {
        value: {
          base: '#3370b8',        // 기본 (light + 기본 brand)
          _dark: '#4a8cd4',       // 다크 + 기본 brand
          _jira: '#0052cc',       // 라이트 + jira
          _jiraDark: '#4c9aff',   // 다크 + jira (직교)
        },
      },
    },
  },
}

직교 결합이 없으면 jira 다크에서 일반 다크 색이 나온다 — _jiraDark를 명시해야 cascade가 정확.

6) 토큰 SSOT → 두 라이브러리에 동시 주입

DTCG JSON 한 벌을 Style Dictionary로 변환:

# tokens.json (DTCG)
#   → CSS variables (globals.css)
#   → tailwind.config.ts theme.extend
#   → panda preset
sd build

07-panda-tailwind-interop에서 다루는 패턴. 핵심은 토큰 이름이 두 시스템에서 동일해야 한다는 것.

// styled-system/preset (Panda)
'bg.accent': { value: { base: '...', _dark: '...' } }
 
// Tailwind config
colors: { 'bg-accent': 'var(--color-bg-accent)' }

이름 매핑 규칙: Panda의 bg.accent → Tailwind utility는 bg-bg-accent (또는 bg-accent 같이 prefix 정리).

7) Runtime API — 토글 코드

<html>에 attribute를 박는 코드는 Panda/Tailwind 모두 같다:

export function setTheme(theme: 'light' | 'dark') {
  document.documentElement.setAttribute('data-theme', theme);
  localStorage.setItem('theme', theme);
}
 
export function setBrand(brand: 'jira' | 'confluence' | 'trello') {
  document.documentElement.setAttribute('data-brand', brand);
  localStorage.setItem('brand', brand);
}

What-if — 잘못 쓰면 어떻게 깨지는가

  • 함정 1 — Tailwind v3 default darkMode로 두기: 기본은 'media'. 사용자 토글 불가. 반드시 'class' 또는 'selector'로.
  • 함정 2 — Panda _dark condition을 만들었는데 <html data-theme> 안 박음: condition은 selector 매치. HTML에 attribute 없으면 영원히 base만.
  • 함정 3 — Tailwind v3에서 darkMode: ['selector', '[data-theme="dark"]']가 안 먹힘: v3.4.1 이전 버전이거나, selector 안에 & 위치를 잘못. 정확히 [data-theme="dark"](공백 뒤 & 없이도 작동) 또는 [data-theme="dark"] & 두 형태 모두 가능 — 문서 참조.
  • 함정 4 — 두 시스템이 다른 selector를 보고 있음: Panda는 [data-theme=dark], Tailwind는 .dark 클래스. 결과: 한쪽 컴포넌트만 다크. 반드시 같은 selector로 정렬.
  • 함정 5 — _darkdark: 둘 다 마크업에 직접: <button class={css({ _dark: {...} })} {...} class="dark:bg-gray-900">. 의도 분산. semantic token 한 곳에만 분기 정의.
  • 함정 6 — custom condition 이름 충돌: Panda는 _jira, Tailwind에는 jira: — 다른 컴포넌트가 다른 키워드를 쓰면 디버깅 지옥. 팀 컨벤션으로 동일한 이름 강제.
  • 함정 7 — @variant (Tailwind v4)에서 & 없이 작성: v4의 @variant는 nesting 안에 &반드시 포함해야 한다. 빠뜨리면 selector가 잘못 빌드.

Insight — 흥미로운 이야기

Panda CSS의 conditions API는 Stitches의 media + variants + compoundVariants 3개를 하나의 1급 시민으로 합친 것이다.

Stitches(2020)는 반응형media, 상태&:hover, 복합compoundVariants로 흩어져 있었다. Panda의 Segun Adebayo(Chakra UI 창시자, Panda 메인테이너)는 이 셋이 사실 같은 추상임을 발견했다 — “condition = 임의의 CSS selector + media query를 condition 키로 등록 가능”. _hover, _dark, _landscape, _print, _brandJira 모두 동등.

이게 강력한 이유: 새 차원을 추가할 때 프레임워크 수정이 필요 없다. 디자인 시스템 팀이 _a11yVisualImpairment: '[data-vi=true] &' 같은 condition을 자기 프로젝트에서만 추가할 수 있다. Tailwind v3는 그게 addVariant 플러그인을 짜야 했고, v4는 @variant로 비슷한 유연성을 CSS 측에 가져왔다.

흥미로운 디테일: Adam Wathan(Tailwind)과 Segun Adebayo(Panda)는 서로의 결정을 공개적으로 인용한다. v4 발표 영상에서 Adam은 “Panda CSS와 Vanilla Extract가 보여준 CSS variables 우선 패턴을 우리도 받아들였다”고 언급했고, Panda 문서는 Tailwind의 utility-first 철학을 인정하면서 recipe 시스템으로 차별화한다. 두 진영의 경쟁이 아닌 수렴이 2023~2025년 CSS 진영의 큰 그림.

마지막 통찰: conditions는 사실 6장의 마지막 추상이다. dark mode, multi-brand, RTL, system preferences — 이 챕터의 모든 주제가 condition 한 개씩으로 환원된다. _dark, _jira, _rtl, _reducedMotion, _forcedColors — Panda 사용자는 같은 API로 7개 챕터를 푼다. 추상화가 7개 문제를 1개로 줄였다.


요약

  • [data-theme="dark"] selector를 모든 시스템의 공통 좌표로 정하라.
  • Panda는 conditions._dark, Tailwind v3는 darkMode: ['selector', '...'], Tailwind v4는 @variant dark (...).
  • Panda의 conditions임의 selector 등록이 가능해 brand, contrast, locale, motion 모두 같은 API로.
  • 두 시스템이 다른 selector를 보면 페이지 반쪽만 테마 적용 — 반드시 정렬.
  • Tailwind v4의 @variant는 v3의 plugin 작성 부담을 CSS 측 한 줄로 줄였다.