Panda CSS: 빌드 타임에 CSS를 뽑는 제로 런타임 스타일링, 토큰·Recipes, RSC 호환, Tailwind 비교
이 글의 핵심
Panda CSS는 빌드 시점에 정적 CSS와 타입 안전 API를 생성하는 “제로 런타임” 스타일 도구입니다. 토큰·프리셋으로 디자인 시스템을 코드로 고정하고, Recipes·Patterns로 반복 UI를 추상화하며, React Server Component와도 잘 맞습니다. 이 글은 설정부터 실무 컴포넌트 설계·Tailwind와의 선택 기준까지 한 흐름으로 연결합니다.
이 글의 핵심
Panda CSS는 CSS-in-JS의 개발 경험(코드로 스타일을 표현하고 타입 추론을 받는 경험)과 런타임 비용을 줄이려는 요구를 동시에 만족시키기 위해 등장한 도구입니다. 런타임에 <style>을 삽입하거나 JavaScript로 규칙을 계산하는 대신, 빌드 타임에 정적 CSS와 타입 정의를 생성하고 애플리케이션은 클래스명과 CSS 파일만 로드합니다.
아래 예제는 Panda CSS 최신 안정 계열의 일반적인 관용구를 기준으로 합니다. 설치된 패키지 버전에 따라 import 경로·설정 키가 다를 수 있으므로, 적용 시 공식 문서와 프로젝트의 package.json을 함께 대조하시기 바랍니다.
Panda CSS의 핵심 개념
제로 런타임(Zero Runtime)이 의미하는 것
전통적인 런타임 CSS-in-JS(예: 테마 프로바이더가 스타일을 주입하는 방식)는 사용자 기기에서 스타일 계산·삽입 비용이 발생할 수 있습니다. Panda는 이 비용을 빌드 파이프라인으로 이동합니다. 개발자는 TypeScript/JavaScript로 스타일 의도를 작성하지만, 배포 산출물에는 미리 생성된 클래스와 정적 스타일시트가 포함됩니다.
그 결과 스타일 엔진이 애플리케이션 번들을 부풀리지 않고, 생성 규칙이 빌드 시점에 확정되므로 SSR 직후의 스타일 깜빡임이나 런타임 주입 순서 문제도 생기지 않습니다. 생성된 타입 덕분에 토큰이나 variant 이름을 잘못 쓰면 에디터와 tsc에서 바로 드러나며, strictTokens 옵션을 켜면 토큰에 없는 임의 값도 타입 에러로 막을 수 있습니다.
다만 “제로 런타임”이 JavaScript가 전혀 없다는 뜻은 아닙니다. 생성된 css()나 레시피 함수는 런타임에 객체를 받아 클래스 이름 문자열을 만드는 작은 헬퍼로 남습니다. 스타일 규칙 자체를 브라우저에서 계산하거나 <style>을 삽입하지 않는다는 의미입니다.
코드 생성(Codegen) 워크플로
Panda를 도입하면 보통 설정 파일(panda.config.ts 등)과 소스의 스타일 호출을 기준으로 styled-system 디렉터리(이름은 설정에 따라 다름)가 생성됩니다. 이 디렉터리에는 css 함수, 조건(condition), 레시피 타입 등 프로젝트에 맞춘 API 표면이 만들어집니다.
이 단계가 필요한 이유는 토큰과 레시피 정의를 한곳에서 읽어 일관된 타입과 최소한의 런타임 헬퍼만 남기기 위해서입니다. 팀이 커질수록 “문자열 클래스 나열”이 아니라 계약된 토큰 집합으로 말하는 편이 유지보수에 유리합니다.
유틸리티·토큰·시맨틱의 균형
Panda는 단순히 유틸리티 나열 도구가 아니라, 토큰 계층과 시맨틱 토큰(예: colors.fg.default)을 두어 디자인 시스템을 코드로 표현하는 것을 권장합니다. 이는 “디자이너가 정의한 스케일”과 “컴포넌트가 참조하는 의미”를 분리해, 테마 변경·다크 모드·브랜드 확장 시 수정 범위를 줄이는 데 도움이 됩니다.
설정과 프리셋
최소 설치와 진입점
프로젝트에는 보통 @pandacss/dev가 devDependency로 들어가고, PostCSS 플러그인(또는 panda --watch CLI)으로 빌드 체인에 연결됩니다. 하는 일은 소스를 감시해 CSS를 내보내고, 설정이 바뀌면 codegen을 다시 실행하는 것입니다.
대표적인 초기화 흐름은 다음과 같습니다.
# 패키지 매니저에 맞게 선택 (예: pnpm)
pnpm add -D @pandacss/dev postcss
pnpm panda init --postcss
panda init은 panda.config.ts를 만들고 styled-system 디렉터리를 생성하며, --postcss를 붙이면 postcss.config 파일도 만듭니다. styled-system은 보통 .gitignore에 넣으므로, 클론 직후에도 생성물이 있도록 package.json에 "prepare": "panda codegen" 스크립트를 직접 추가하는 것이 공식 문서의 권장 흐름입니다. 전역 CSS 파일에는 @layer reset, base, tokens, recipes, utilities;를 선언해야 생성된 CSS가 들어갈 자리가 생깁니다. 모노레포에서는 어느 패키지가 소스 루트인지를 명확히 해야 include/exclude가 어긋나지 않습니다.
defineConfig로 루트 구성 잡기
panda.config.ts는 Panda의 단일 진실 공급원에 가깝습니다. 여기서 프리셋, 테마(토큰), 조건(미디어 쿼리·다크 모드), 글로벌 CSS, 레시피 등을 선언합니다.
import { defineConfig } from '@pandacss/dev';
export default defineConfig({
// 브라우저 기본 스타일 리셋 포함 여부
preflight: true,
// 소스에서 Panda가 스캔할 파일 범위
include: ['./src/**/*.{ts,tsx,js,jsx}'],
exclude: [],
// presets를 지정하지 않으면 기본 프리셋(@pandacss/preset-panda)이 쓰이고,
// theme.extend는 그 위에 토큰을 덧붙입니다.
theme: {
extend: {
tokens: {
colors: {
brand: { value: '#2563eb' },
},
},
},
},
outdir: 'styled-system',
});
include가 너무 넓으면 빌드가 느려지고, 너무 좁으면 사용한 스타일이 추출되지 않습니다. 디자인 시스템 패키지를 별도로 둔 경우 그 패키지 경로도 포함해야 합니다. 또 extend 없이 theme.tokens에 직접 쓰면 기본 프리셋의 토큰을 덮어써서 blue.500 같은 기본 색이 사라지므로, 의도한 경우가 아니라면 extend 안에 넣습니다.
프리셋(Preset)으로 팀 규칙 공유하기
프리셋은 재사용 가능한 Panda 설정 묶음입니다. 조직 단위로 “브랜드 토큰 + 공통 레시피 + 린트에 가까운 금지 규칙”을 프리셋으로 배포하면, 여러 앱이 동일한 스타일 계약을 공유할 수 있습니다.
import { definePreset } from '@pandacss/dev';
export const acmePreset = definePreset({
name: '@acme/panda-preset',
theme: {
extend: {
tokens: {
fonts: {
body: { value: 'Inter, system-ui, sans-serif' },
},
},
},
},
});
루트 설정에서는 presets: ['@pandacss/preset-panda', acmePreset] 형태로 합성합니다. 여기서 흔한 실수는 presets: [acmePreset]만 적는 것입니다. presets를 직접 지정하면 기본 프리셋인 @pandacss/preset-panda가 자동으로 들어가지 않아, 기본 색·간격·텍스트 스타일 토큰이 한꺼번에 사라집니다(유틸리티와 조건을 정의하는 @pandacss/preset-base는 항상 포함됩니다). 프리셋은 배열 순서대로 병합되어 뒤쪽이 앞쪽을 덮으므로, 팀 프리셋을 기본 프리셋 뒤에 둡니다.
Recipes와 Patterns
Recipe: variant 기반 컴포넌트 스타일
Recipe는 버튼·배지처럼 도메인 컴포넌트에 붙는 변형 세트를 한곳에 모읍니다. size, variant, tone 같은 축을 정의하고 각 값에 해당하는 스타일을 선언하므로, 디자인 시스템이 허용한 값만 props로 받을 수 있게 됩니다.
Recipe는 다음 네 가지 키로 구성됩니다.
- base: 모든 변형에 공통으로 적용되는 기본 스타일
- variants: 축마다 허용 값과 스타일
- compoundVariants: 여러 축이 동시에 만족될 때만 적용되는 규칙
- defaultVariants: 생략 시 기본값
defineRecipe로 만든 설정 레시피는 panda.config.ts의 theme.extend.recipes에 등록해야 코드가 생성됩니다. 아래 레시피를 recipes: { button: buttonRecipe }로 등록하면 styled-system/recipes에 button 함수가 생기고, Panda는 소스에서 실제로 쓰인 variant 조합의 CSS만 만들어 냅니다.
// 예: panda.config.ts 내부 또는 별도 파일에서 defineRecipe 사용
import { defineRecipe } from '@pandacss/dev';
export const buttonRecipe = defineRecipe({
className: 'btn',
base: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 'semibold',
borderRadius: 'md',
},
variants: {
size: {
sm: { textStyle: 'sm', px: '3', py: '1.5' },
md: { textStyle: 'md', px: '4', py: '2' },
},
variant: {
solid: {},
outline: { borderWidth: '1px', borderColor: 'gray.300' },
},
},
defaultVariants: {
size: 'md',
variant: 'solid',
},
});
Recipe 축이 늘어날수록 조합 수가 곱으로 늘어납니다. compoundVariants는 특정 조합에 스타일을 덧붙이는 기능일 뿐 조합 자체를 금지하지는 못하므로, 허용하지 않을 조합은 컴포넌트 props 타입에서 막거나 아예 별도 Recipe로 쪼개는 편이 낫습니다.
Pattern: 반복 레이아웃 추상화
Pattern은 스택·그리드·센터링처럼 자주 쓰는 레이아웃을 이름 붙여 재사용하는 레이어입니다. 매번 display: flex, gap, alignItems를 나열하는 대신 팀이 합의한 레이아웃 프리미티브로 표현하고, gap 같은 속성을 간격 토큰으로만 받게 해서 임의 값이 섞이지 않게 합니다. stack, hstack, vstack, center, grid, wrap 같은 패턴은 기본으로 들어 있으므로, 직접 정의하는 것은 그 밖의 팀 고유 레이아웃입니다.
import { definePattern } from '@pandacss/dev';
// 아이콘과 텍스트를 가로로 늘어놓고 넘치면 줄바꿈하는 클러스터
export const cluster = definePattern({
description: '가로로 감싸며 배치하는 클러스터',
properties: {
gap: { type: 'token', value: 'spacing' },
align: { type: 'property', value: 'alignItems' },
},
defaultValues: {
gap: '2',
align: 'center',
},
// 패턴의 실제 스타일은 transform이 반환하는 객체입니다.
transform(props) {
const { gap, align, ...rest } = props;
return { display: 'flex', flexWrap: 'wrap', gap, alignItems: align, ...rest };
},
});
이 패턴은 patterns: { extend: { cluster } }로 등록하면 styled-system/patterns에 cluster() 함수가, jsxFramework: 'react'를 설정했다면 <Cluster> 컴포넌트도 생성됩니다. transform이 없으면 패턴은 아무 스타일도 만들지 않습니다.
Pattern과 Recipe의 역할 분담을 “레이아웃 vs 컴포넌트 외형”으로 나누면, 디자인 시스템 라이브러리 구조가 명확해집니다.
토큰과 디자인 시스템
토큰 계층 설계
Panda에서 토큰은 색·간격·라운드·폰트·그림자 등의 스케일을 의미합니다. 토큰 계층은 보통 세 단계로 나눕니다.
- 원시 토큰(raw): 브랜드 팔레트, 기본 스케일
- 시맨틱 토큰:
bg.canvas,fg.muted,border.subtle처럼 “의미”로 소비 - 컴포넌트 토큰(선택): 특정 컴포넌트만의 높이·패딩 등
시맨틱 토큰은 semanticTokens에 { value: { base: '{colors.gray.900}', _dark: '{colors.gray.50}' } }처럼 조건별 값을 지정할 수 있어서, 컴포넌트가 시맨틱 토큰만 참조하면 다크 모드 전환 시 컴포넌트 코드를 건드릴 필요가 없습니다.
텍스트 스타일과 타이포 스케일
타이포그래피는 UI 일관성의 핵심입니다. Panda에서는 textStyle 등으로 폰트 크기·행간·자간을 묶어 재사용할 수 있습니다. 페이지와 컴포넌트가 임의의 font-size를 쓰기보다, 정해진 스타일 토큰만 참조하게 하면 디자인 감사(audit)가 쉬워집니다.
다크 모드·테마: 조건(Conditions) 활용
테마는 단순히 색 두 벌이 아니라 대비·포커스 링·보더 가시성까지 함께 바뀌어야 합니다. Panda의 조건은 _dark, _hover 같은 셀렉터 계열을 일관되게 다루는 데 사용됩니다. 기본 _dark 조건은 조상 요소의 .dark 클래스를 기준으로 동작하고, 운영체제 설정을 그대로 따르는 미디어 쿼리 버전은 _osDark로 따로 있습니다. 사용자가 테마를 직접 고르는 UI라면 .dark 클래스를 토글하고, 그렇지 않다면 conditions에서 _dark를 프로젝트 방식에 맞게 재정의하는 편이 혼란이 적습니다. Panda는 모든 토큰을 CSS 변수로 내보내므로, 클래스 하나만 바꿔 테마를 전환할 수 있습니다.
토큰만 늘리고 사용처를 통제하지 않으면 비슷한 토큰이 계속 생겨납니다. 코드 리뷰와 Figma 토큰 동기화 정책을 함께 운영하는 편이 좋습니다.
RSC(React Server Components)와 호환성
왜 Panda가 RSC 담론에 자주 등장하는가
서버 컴포넌트는 클라이언트 번들을 키우지 않고 UI를 준비하려는 모델입니다. 이 관점에서 Panda의 제로 런타임 접근은 방향성이 맞습니다. 스타일이 빌드 시 생성된 클래스로 끝나면, 서버에서 HTML을 만들 때도 동일한 클래스명을 안정적으로 쓸 수 있습니다.
서버 vs 클라이언트 경계
규칙은 단순합니다. 서버 컴포넌트에서는 css() 호출로 클래스 문자열을 만들어 마크업에 붙이면 되고, 클라이언트 컴포넌트는 상태·이벤트·애니메이션처럼 브라우저 API가 필요한 경우에만 둡니다.
문제는 스타일이 아니라 어떤 코드가 클라이언트 번들로 끌려가느냐입니다. Panda 자체보다는 프레임워크의 'use client' 규칙과 서드파티 컴포넌트가 경계를 흔듭니다.
Next.js App Router에서 확인할 것
Next.js에서 문제가 생기는 지점은 대개 세 곳입니다. codegen 산출물 경로가 tsconfig의 경로 별칭과 맞지 않으면 import가 깨지고, @layer 선언이 들어 있는 전역 CSS를 app/layout.tsx에서 import하지 않으면 생성된 스타일이 페이지에 실리지 않습니다. 또 상태에 따라 바뀌는 스타일을 클라이언트 훅으로만 계산하면 그 컴포넌트 전체가 클라이언트 경계로 끌려가므로, 가능하면 data-* 속성과 조건 스타일(_open, _selected 등)로 표현하는 편이 낫습니다.
Tailwind CSS vs Panda CSS
철학의 유사점과 차이점
Tailwind는 유틸리티 퍼스트 접근과 넓은 생태계가 강점입니다. Panda도 유틸리티를 쓰지만, 토큰·타입·레시피·코드 생성을 중심에 두어 디자인 시스템을 코드로 관리하는 쪽에 초점을 둡니다. 둘 다 빌드 시점에 사용된 스타일만 정적 CSS로 뽑는다는 점에서는 같습니다.
| 관점 | Tailwind | Panda CSS |
|---|---|---|
| 설정 | tailwind.config 중심 | panda.config + codegen |
| 스타일 표현 | 클래스 문자열 위주 | css 객체 + 레시피·패턴 |
| 타입 안전 | 플러그인·제3자 도구에 의존 가능 | 생성 타입으로 강하게 유도 가능 |
| 런타임 | 없음 (사용된 클래스만 빌드 시 생성) | 클래스명 조합용 작은 헬퍼만 존재 |
| 학습 곡선 | 자료·예제 풍부 | DS·TS 친화적 팀에 유리 |
선택 기준(실무 의사결정)
레퍼런스·플러그인·템플릿 생태계가 가장 중요하거나 팀이 이미 Tailwind에 숙련되어 있다면 Tailwind가 유리합니다. 토큰·variant·패턴을 엄격히 관리해야 하고 모노레포에서 디자인 시스템 패키지를 코드로 공유해야 한다면 Panda가 맞는 경우가 많습니다. 함께 쓴다면 레거시 화면은 Tailwind, 새 디자인 시스템은 Panda처럼 경계를 나누어 점진적으로 도입하는 편이 현실적입니다. 이때 두 도구 모두 리셋 CSS를 넣으므로 한쪽의 preflight를 끄고, @layer 순서가 충돌하지 않는지 확인해야 합니다.
실전 컴포넌트 라이브러리
라이브러리 구조 제안
디자인 시스템 패키지를 만든다면 다음 구조가 읽기 쉽습니다.
tokens/— 브랜드·시맨틱 토큰recipes/— 버튼·입력·배지patterns/— 스택·그리드·인라인 클러스터components/— React/Vue 등 래퍼(프로젝트별)
요점은 스타일 계약과 프레임워크 래퍼를 분리하는 것입니다. 스타일은 Panda가, 상호작용 상태는 각 프레임워크가 담당합니다.
버튼 컴포넌트 예시
아래는 앞의 button 레시피를 쓰는 React 예시입니다. import 경로는 outdir 설정에 맞추어야 합니다.
// Button.tsx
import type { RecipeVariantProps } from '../styled-system/types';
import { cx } from '../styled-system/css';
import { button } from '../styled-system/recipes';
type ButtonVariants = RecipeVariantProps<typeof button>;
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
ButtonVariants & { loading?: boolean };
export function Button({ loading, className, children, ...props }: ButtonProps) {
// variant props(size, variant)와 나머지 DOM 속성을 분리
const [variantProps, rest] = button.splitVariantProps(props);
return (
<button
type="button"
{...rest}
className={cx(button(variantProps), className)}
disabled={loading || rest.disabled}
aria-busy={loading || undefined}
>
{loading ? '…' : children}
</button>
);
}
splitVariantProps는 레시피가 아는 variant props와 나머지를 나눠 줍니다. 이렇게 나누지 않고 size나 variant까지 <button>에 그대로 펼치면 DOM에 알 수 없는 속성이 붙고, 반대로 나머지 속성을 펼치는 것을 잊으면 onClick 같은 핸들러가 전달되지 않습니다. className을 cx로 합치는 이유는 소비자가 레이아웃 목적으로 클래스를 덧붙일 수 있게 하기 위해서입니다. type="button"을 먼저 두고 rest를 뒤에 펼쳤으므로 소비자가 type="submit"을 넘기면 그 값이 이깁니다.
카드·표면(surface) 패턴
카드는 배경·테두리·그림자·라운드의 조합입니다. 토큰만 잘 정의했다면 Recipe 없이 css() 한 번으로도 충분한 경우가 많고, 카드 변형이 많아지면 그때 Recipe로 승격하면 됩니다. 아래 예시의 bg.surface, fg.default, border.subtle은 semanticTokens에 미리 정의해 둔 시맨틱 토큰이라고 가정합니다.
// Card.tsx — 개념 예시
import { css } from '../styled-system/css';
const cardClass = css({
bg: 'bg.surface',
color: 'fg.default',
borderWidth: '1px',
borderColor: 'border.subtle',
borderRadius: 'lg',
boxShadow: 'sm',
p: '4',
});
export function Card(props: React.PropsWithChildren<{ className?: string }>) {
return <div className={[cardClass, props.className].filter(Boolean).join(' ')}>{props.children}</div>;
}
자주 나는 문제는 토큰 이름을 썼는데 그 토큰이 테마에 정의되어 있지 않은 경우입니다. Panda는 이런 값을 에러 없이 그대로 CSS 값으로 내보내므로 화면에서만 티가 납니다. strictTokens를 켜 두면 타입 단계에서 잡을 수 있고, 디자인 시스템 저장소에 스토리북 시각 회귀 테스트를 붙이면 테마별 누락도 확인할 수 있습니다.
문제 해결
생성물이 갱신되지 않을 때
먼저 해당 파일이 include 경로에 들어 있는지 확인하고, 개발 서버에서 PostCSS 플러그인이나 panda --watch가 실제로 돌고 있는지, 설정 변경 후 panda codegen을 다시 실행했는지를 차례로 봅니다.
스타일 값이 화면에 안 나오는 또 다른 흔한 원인은 정적 분석의 한계입니다. Panda는 소스를 실행하지 않고 읽기만 하므로, css({ color: isError ? 'red.500' : 'gray.700' })처럼 리터럴이 소스에 드러나 있으면 추출되지만 css({ color: `${tone}.500` })처럼 문자열을 런타임에 조합하면 해당 CSS가 생성되지 않습니다. 이런 값은 Recipe의 variant로 미리 선언하거나, staticCss 설정으로 생성할 값을 명시하거나, CSS 변수로 넘겨야 합니다.
토큰 이름은 엄격하게, 사용은 단순하게
토큰 네이밍은 일관된 규칙(예: bg.*, fg.*, border.*)을 문서화하며, 컴포넌트에서는 시맨틱 토큰만 참조하게 합니다. 이렇게 해야 브랜드 색이 바뀌어도 컴포넌트 수정 범위가 작아집니다.
맺음말
Panda CSS는 런타임 스타일 엔진 없이 토큰·레시피·패턴을 코드베이스에 넣을 수 있게 해 주지만, 그 대가로 스타일을 정적으로 분석 가능한 형태로 써야 한다는 제약이 따릅니다. 이 제약이 팀의 디자인 시스템 운영 방식과 맞는지가 Tailwind와의 선택에서 가장 중요한 기준입니다.