🧩 Design System7. Panda × Tailwind 호환Build Pipeline & CSS Cascade Order — PostCSS·@layer·specificity 충돌 해결

Build Pipeline & CSS Cascade Order — PostCSS·@layer·specificity 충돌 해결

이 문서가 답하는 질문: Panda와 Tailwind가 같은 셀렉터를 emit할 때 누가 이기는가? PostCSS 파이프라인을 어떻게 구성해야 결정적으로 한쪽이 이기게 만들 수 있는가? 한 줄 답 (Pyramid Top): 충돌은 PostCSS 플러그인 순서가 아니라 CSS Cascade Layers (@layer)의 명시적 정의 순서로 해결한다. layer 안의 specificity와 import 순서는 그 다음 이야기다.


Why — 왜 존재하는가

두 시스템이 같은 페이지에서 작동할 때 다음 3개 충돌이 일어난다.

충돌증상원인
Preflight 중복form control 스타일이 깨짐Tailwind preflight(reset)와 Panda preset.base가 동시 적용
같은 셀렉터 emit.bg-primary가 한쪽 정의로 덮임두 시스템 모두 @layer utilities에 정의
Specificity wars!important 폭주layer 순서 미정의 → import 순서 의존 → 빌드마다 다름

CSS Cascade Layers(2022 표준, 모든 모던 브라우저 지원)는 이 문제를 원천적으로 해결한다 — 어느 layer가 이기는지를 명시적으로 선언할 수 있기 때문이다.


How — 어떻게 동작하는가

핵심 원칙:

  1. @layer 선언 순서가 cascade 우선순위. 뒤에 선언된 layer가 이긴다.
  2. layer 안에서는 specificity·순서가 결정. 따라서 같은 layer에 두 시스템이 들어가면 비결정적.
  3. !important는 layer 외부로 빠진다. 한 번 쓰면 layer 시스템이 무너지므로 절대 금지.

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

권장 layer 구조

src/styles/app.css:

/* 1) Layer 순서 선언 — 뒤가 이김 */
@layer reset, tokens, base, recipes, utilities-panda, utilities-tw, overrides;
 
/* 2) Reset — 둘 중 하나만 (양쪽 다 켜면 form control 깨짐) */
@layer reset {
  @import 'tailwindcss/preflight';
  /* OR
  @import './panda-reset.css';  */
}
 
/* 3) Token — Style Dictionary 출력 */
@layer tokens {
  @import './tokens.css';
}
 
/* 4) Base — 디자인 시스템 자체의 element 스타일 */
@layer base {
  body { font-family: var(--font-sans); }
}
 
/* 5) Panda recipe + utility */
@import './styled-system/styles.css' layer(recipes); /* Panda output */
 
/* 6) Tailwind utility */
@import 'tailwindcss/utilities' layer(utilities-tw);
 
/* 7) Override (꼭 필요한 경우만) */
@layer overrides {
  .legacy-page .button { /* ... */ }
}

이 순서면:

  • Tailwind utility(utilities-tw)가 Panda recipe(recipes)보다 이긴다 → 마지막에 className으로 덮기 가능.
  • Reset은 한 번만 적용.
  • Override는 가장 마지막 layer — !important 없이도 이김.

Tailwind v4 @theme 활용

Tailwind v4는 자체적으로 @layer 구조를 갖는다:

@import 'tailwindcss';
 
@theme {
  --color-primary: oklch(0.55 0.18 250);
}

이걸 Panda와 함께 쓸 땐:

@layer reset, theme, base, recipes, utilities;
 
@import 'tailwindcss/theme' layer(theme);
@import 'tailwindcss/preflight' layer(reset);
@import 'tailwindcss/utilities' layer(utilities);
 
@import './styled-system/styles.css' layer(recipes);

PostCSS 플러그인 순서

postcss.config.js:

module.exports = {
  plugins: {
    '@pandacss/dev/postcss': {}, // Panda가 먼저 styled-system 출력 갱신
    'tailwindcss': {},           // 그 다음 Tailwind 출력
    'autoprefixer': {},
    'cssnano': {},               // 프로덕션만
  },
}

플러그인 순서는 처리 순서이지 cascade 순서가 아니다. cascade는 위의 @layer 선언이 결정한다.

Vite + Next.js 설정

// next.config.mjs
import nextra from 'nextra'
 
const withNextra = nextra({/* ... */})
 
export default withNextra({
  webpack(config) {
    return config
  },
  experimental: {
    optimizePackageImports: ['styled-system'], // Panda tree-shake
  },
})
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
 
export default defineConfig({
  plugins: [react()],
  css: {
    postcss: './postcss.config.js',
  },
})

Reset 중복 회피

Panda preset.base와 Tailwind preflight는 거의 같은 일을 하지만 미묘하게 다르다.

항목Tailwind preflightPanda preset.base
*, ::before, ::afterbox-sizing: border-box, border: 0box-sizing: border-box
bodyline-height: 1.5line-height: var(--leading-base)
Form elementsfont: inherit; color: inherit비슷하나 다름
imgdisplay: block; max-width: 100%동일

둘 다 켜면: 후행 적용이 이김. <button> 스타일이 한쪽에서 reset 후 다른 쪽이 다시 reset → 미묘한 차이가 누적.

해결: 둘 중 하나만 활성화. Panda 사용자는:

// panda.config.ts
preflight: false, // Tailwind에 위임

Tailwind 사용자는:

// panda.config.ts
preflight: true,
// 그리고 tailwind.config.ts에서 preflight 끔
corePlugins: { preflight: false }

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

  • @layer 선언 없이 두 시스템 import: cascade 순서가 import 순서에 의존 → 다른 빌더(turbopack vs webpack)에서 결과 다름. 항상 명시적 layer.
  • !important 한 번 쓰기: layer 외부로 빠지므로 어느 layer도 이기지 못함. 디자인 시스템 라이브러리 코드에 !important가 들어가면 사용자 코드에서 override 불가.
  • PostCSS 플러그인 순서가 cascade 결정한다고 착각: Tailwind가 먼저 처리되든 나중이든 생성된 CSS의 cascade@layer가 결정. 처리 순서는 어느 시스템이 어느 셀렉터를 만드는지에만 영향.
  • CSS Modules와 layer 동시 사용: CSS Modules는 자체 scope, @layer는 cascade. 둘은 충돌 안 하지만, CSS Modules가 만든 클래스는 어느 layer에도 안 들어감 → 디자인 시스템 토큰을 무시할 위험.
  • Preflight 중복: form control 스타일이 미묘하게 어긋남. 둘 중 하나만.

Insight — 흥미로운 이야기

CSS Cascade Layers는 2018년 Jen Simmons(Apple Safari 팀)의 제안으로 시작됐다. 당시 CSS의 가장 큰 심리적 부담은 “내 스타일이 왜 안 먹지?”였고, specificity·source order·!important로 얽힌 cascade는 큰 코드베이스에서 예측 불가능했다.

Chris Coyier가 2021년 “The Future of CSS: Cascade Layers”라는 글에서 이를 “BEM·utility-first·CSS-in-JS의 십수 년 논쟁을 종결시킬 수 있는 표준”이라 평한 이유가 여기 있다. 처음으로 *“이 코드는 저 코드를 절대 못 덮는다”*를 표준 문법으로 선언할 수 있게 됐기 때문이다.

흥미로운 반전: Tailwind v3은 @layer base/components/utilities라는 Tailwind 자체 directive를 먼저 만들었고, 이는 표준 @layer비슷하지만 다른 것이었다 — Tailwind는 PostCSS 변환으로 layer를 순서로 풀어버렸다. v4에 와서야 표준 @layer directive를 그대로 emit하게 됐다. 즉, Tailwind v3와 Panda를 같이 쓰던 시절엔 v3의 가짜 layer 때문에 진짜 layer를 못 쓰는 우스운 상황이 있었다.

Panda CSS는 처음부터 표준 @layer를 가정하고 설계됐다. @layer reset, base, tokens, recipes, utilities라는 표준 이름은 Panda 문서에서 처음 제안됐고, 다른 zero-runtime CSS-in-JS(vanilla-extract, Stitches)도 비슷하게 따라왔다.


요약

  • 충돌 해결의 단일 도구는 @layer 선언 순서.
  • PostCSS 플러그인 순서는 처리 순서일 뿐, cascade 순서가 아니다.
  • 권장: @layer reset, tokens, base, recipes, utilities-panda, utilities-tw, overrides.
  • Preflight·base reset은 둘 중 하나만 활성화.
  • !important는 layer 시스템을 무너뜨리므로 절대 금지.