🧩 Design System5. Composition (Slot·Polymorphism)Slot Recipes — 한 컴포넌트의 여러 부분에 variant 분배

Slot Recipes — 한 컴포넌트의 여러 부분에 variant 분배

이 문서가 답하는 질문: Card에 root·header·body·footer가 있을 때, variant="elevated"를 어떻게 4 부분에 동시에 적용하는가? 한 줄 답 (Pyramid Top): Slot recipe는 한 컴포넌트를 여러 이름 있는 부분(slots)으로 쪼개고, variant를 그 부분들에 분배하는 단일 함수다. Panda에서는 sva({ slots, base, variants }), Tailwind 진영에서는 tv({ slots, variants }).


Why — 왜 “slot recipe”가 필요한가

4장의 일반 recipe (cva, tv, Panda cva)는 한 element에만 클래스를 부여한다. 즉, Button처럼 단일 element 컴포넌트에 잘 맞는다.

그런데 다음 같은 경우는 어떻게 할까?

<Card variant="elevated" tone="brand">
  <Card.Header>제목</Card.Header>
  <Card.Body>본문</Card.Body>
  <Card.Footer>액션</Card.Footer>
</Card>

variant="elevated"root에 shadow를 주고, header에 더 두꺼운 border-bottom을 주고, footer의 padding을 약간 줄여야 할 수도 있다. 하나의 variant가 4개 element에 다른 클래스를 만들어내야 한다.

“1 variant → N elements” 매핑을 표준화한 것이 slot recipe다.

풀려는 문제이전의 해법한계
한 컴포넌트의 여러 부분에 일관된 variant 분배element별로 cva 4번 호출4 함수의 variant prop이 동기화되어야 함 (drift 위험)
Card의 variant와 Header의 variant가 같은 값임을 보장부모에서 모든 자식에 prop 전달prop drilling, 자식 컴포넌트마다 prop 정의 반복
컴포넌트 사용자가 4개 element 클래스를 한 번에 받기객체로 묶어서 직접 리턴타입이 손실됨, 재사용 어려움

How — 어떻게 동작하는가

핵심 데이터 흐름:

  1. 정의 시점: slots 배열로 어떤 부분들이 있는지 선언.
  2. basevariants각 slot마다 다른 스타일을 담는 객체 트리.
  3. 호출 시점: variant 값을 넘기면 slot 이름 → 클래스/스타일 함수가 담긴 객체가 리턴됨.
  4. 사용 시점: JSX에서 {...styles.root()} 또는 className={styles.root}로 분해.

What — 구체 사양 / 코드

Panda CSS — sva (Slot Variants Anatomy)

// styled-system/recipes/card.ts
import { sva } from 'styled-system/css'
 
export const card = sva({
  className: 'card',
  slots: ['root', 'header', 'body', 'footer'],
 
  base: {
    root: {
      borderRadius: 'lg',
      bg: 'surface.default',
      overflow: 'hidden',
    },
    header: {
      px: '4',
      py: '3',
      borderBottomWidth: '1px',
      borderColor: 'border.subtle',
      fontWeight: 'semibold',
    },
    body: {
      px: '4',
      py: '4',
    },
    footer: {
      px: '4',
      py: '3',
      borderTopWidth: '1px',
      borderColor: 'border.subtle',
      bg: 'surface.muted',
    },
  },
 
  variants: {
    variant: {
      elevated: {
        root: { shadow: 'md', borderWidth: '0' },
      },
      outlined: {
        root: { borderWidth: '1px', borderColor: 'border.default', shadow: 'none' },
      },
      filled: {
        root: { bg: 'surface.muted', shadow: 'none' },
        header: { bg: 'surface.default' },
      },
    },
    size: {
      sm: { header: { px: '3', py: '2' }, body: { px: '3', py: '3' } },
      md: { header: { px: '4', py: '3' }, body: { px: '4', py: '4' } },
      lg: { header: { px: '6', py: '4' }, body: { px: '6', py: '6' } },
    },
  },
 
  defaultVariants: {
    variant: 'elevated',
    size: 'md',
  },
})

사용:

import { card } from 'styled-system/recipes'
 
export function Card({ variant, size, children }: CardProps) {
  const styles = card({ variant, size })
  return (
    <div className={styles.root}>
      {children}
    </div>
  )
}
 
Card.Header = function CardHeader({ children, variant, size }) {
  const styles = card({ variant, size })
  return <div className={styles.header}>{children}</div>
}
 
Card.Body = function CardBody({ children, variant, size }) {
  const styles = card({ variant, size })
  return <div className={styles.body}>{children}</div>
}

주의: 위 코드는 자식마다 다시 recipe를 호출한다. 비효율적으로 보이지만 Panda는 빌드 타임에 모든 가능한 클래스 조합을 추출해두므로 런타임 비용은 클래스 이름 조회뿐이다. 다만 variant를 매번 prop으로 받기는 어색하므로 보통 3장에서 다룰 compound components 패턴 + Context와 결합한다.

tailwind-variants — tv with slots

// components/card.ts
import { tv, type VariantProps } from 'tailwind-variants'
 
export const card = tv({
  slots: {
    base: 'rounded-lg bg-white overflow-hidden',
    header: 'px-4 py-3 border-b border-gray-200 font-semibold',
    body: 'px-4 py-4',
    footer: 'px-4 py-3 border-t border-gray-200 bg-gray-50',
  },
  variants: {
    variant: {
      elevated: { base: 'shadow-md border-0' },
      outlined: { base: 'border border-gray-300 shadow-none' },
      filled:   { base: 'bg-gray-50 shadow-none', header: 'bg-white' },
    },
    size: {
      sm: { header: 'px-3 py-2', body: 'px-3 py-3' },
      md: { header: 'px-4 py-3', body: 'px-4 py-4' },
      lg: { header: 'px-6 py-4', body: 'px-6 py-6' },
    },
  },
  defaultVariants: { variant: 'elevated', size: 'md' },
})
 
export type CardVariants = VariantProps<typeof card>

사용 — 한 번 호출 + 구조분해:

type CardProps = CardVariants & { children: ReactNode }
 
export function Card({ variant, size, children }: CardProps) {
  const { base, header, body, footer } = card({ variant, size })
  // Context로 slot 함수들을 자식에 내려준다
  return (
    <CardCtx.Provider value={{ header, body, footer }}>
      <div className={base()}>{children}</div>
    </CardCtx.Provider>
  )
}

tv의 핵심 강점은 card() 호출이 함수들의 객체를 리턴한다는 것이다. header()를 호출할 때 추가 className을 합성할 수 있다 — header({ class: 'sticky top-0' }).

비교 표

항목Panda svatailwind-variants tv (slots)
런타임 비용0 (빌드타임 추출)약간 (런타임에 클래스 합성)
출력string/string-likefunction (className builder)
토큰 통합panda.config.ts의 토큰 사용Tailwind 토큰 (또는 CSS variables)
클래스 합성 (twMerge)별도tv가 내장
타입 안전성✓ (codegen)✓ (VariantProps)
호환Panda 전용Tailwind/CVA와 호환

compound variants를 slot에 적용

sva({
  slots: ['root', 'header'],
  base: { /* ... */ },
  variants: {
    variant: { elevated: {...}, outlined: {...} },
    tone: { brand: {...}, danger: {...} },
  },
  compoundVariants: [
    {
      variant: 'elevated',
      tone: 'danger',
      css: {
        root: { borderColor: 'red.500' },
        header: { color: 'red.700' },
      },
    },
  ],
})

<Card variant="elevated" tone="danger" />header의 글자색까지 바뀐다.


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

  • 함정 1 — slot 이름이 도메인마다 다르다

    • Card는 root/header/body/footer, Dialog는 overlay/content/title/description/actions, Toast는 viewport/root/icon/title/description/close.
    • 증상: 학습 비용 증가, 사용자가 “여기서는 header였는데 저기서는 title이네” 하고 헷갈림.
    • 대응: 도메인 단위 slot 어휘 규약을 정한다. 예: 컨테이너는 root, 제목 라인은 title, 본문은 content, 닫기 영역은 close.
  • 함정 2 — 자식 컴포넌트마다 recipe를 호출하여 variant prop을 전달

    • 증상: <Card.Header variant="elevated" size="md"> 매번 똑같은 prop을 써야 함.
    • 대응: Compound component (03-compound-components.md) + Context. 부모가 한 번 recipe를 호출하고 slot 클래스/함수를 Context로 내려준다.
  • 함정 3 — slot마다 다른 as prop을 허용

    • 증상: Header를 <h2>로 쓰고 싶은데 slot recipe가 <div>로 고정.
    • 대응: 각 slot 컴포넌트가 asChild prop을 받게 한다 (다음 챕터).
  • 함정 4 — variant 안에서 모든 slot에 다 채워야 한다고 착각

    • 증상: outlined variant인데 header는 변화 없음 → 빈 객체 header: {}를 넣음.
    • 대응: 적용할 slot만 키로 넣으면 된다. 비어 있는 slot은 생략.

Insight — sva의 어원, 그리고 “anatomy”

svaSlot Variants Anatomy의 약자다. Panda CSS 팀이 차용한 단어 anatomy는 Chakra UI에서 왔다. Chakra는 2020년경 *“한 컴포넌트는 여러 part로 이루어지고, 각 part는 anatomy의 일부”*라는 어휘를 도입했다.

// Chakra v2 - anatomy
import { anatomy } from '@chakra-ui/anatomy'
const cardAnatomy = anatomy('card').parts('container', 'header', 'body', 'footer')

이 *“part”*가 그대로 Radix Primitives의 part(예: Dialog.Trigger, Dialog.Content)로 이어졌고, Panda는 이걸 slot이라 부르며 recipe 시스템에 통합했다.

즉, slot recipe는 Chakra의 anatomy + Stitches의 variants + Tailwind의 utility composition의 합작품이다. 세 진영의 좋은 아이디어가 한 함수에 모인 셈이다.


요약

  • Slot recipe는 한 컴포넌트의 여러 이름 있는 부분에 variant를 분배.
  • Panda: sva({ slots, base, variants }). Tailwind: tv({ slots, variants }).
  • compound variants도 slot별로 적용 가능 — variant + tone 조합이 header만 바꿀 수 있다.
  • 자식 컴포넌트마다 recipe를 호출하지 말고 Context로 slot 함수를 내려준다.
  • slot 이름은 도메인 전체에 통일된 어휘로 관리해야 학습 비용이 낮아진다.