🧩 Design System5. Composition (Slot·Polymorphism)Polymorphism & asChild — 같은 컴포넌트, 다른 태그

Polymorphism & asChild — 같은 컴포넌트, 다른 태그

이 문서가 답하는 질문: <Button>을 어떨 때는 <button>으로, 어떨 때는 <a href>로 렌더하고 싶다. wrapper <div> 없이, 같은 스타일을 유지하면서 어떻게 태그를 바꾸는가? 한 줄 답 (Pyramid Top): 답은 두 갈래다 — polymorphic as prop (타입 폭발, forwardRef 지옥)과 asChild + 자식 cloneElement (Radix가 정착시킨 사실상 표준). 후자가 이긴다.


Why — 같은 디자인, 다른 시맨틱

다음 두 줄을 보자.

<Button variant="primary">로그인</Button>
<a href="/dashboard" className="...button-primary...">대시보드</a>

같은 시각 디자인이지만 하나는 <button>(클릭 핸들러), 하나는 <a>(네비게이션). 시맨틱HTML과 접근성 측면에서 둘 다 필요하다. 그런데 디자인 시스템이 Button 한 컴포넌트만 export하고 싶다면?

풀려는 문제이전의 해법한계
같은 시각 디자인을 여러 태그로<Button> + <LinkButton> 각각 export사실상 같은 코드를 두 번 작성
라우터 라이브러리의 <Link>와 통합Button 내부에서 as={NextLink} 처리모든 라우터마다 분기, 타입 복잡
<button> 안에 <a>를 넣지 않기사용자에게 주의 부탁HTML invalid (interactive nested)
시각 컴포넌트와 시맨틱을 분리wrapper 부모 만들기스타일이 부모로 안 가, 디자인 깨짐

How — 두 길과 왜 후자가 이기는가

① Polymorphic as prop — 타입 폭발

// 단순한 시작
type ButtonProps<T extends ElementType = 'button'> = {
  as?: T
  children: ReactNode
} & ComponentPropsWithoutRef<T>
 
function Button<T extends ElementType = 'button'>({ as, ...rest }: ButtonProps<T>) {
  const Comp = as ?? 'button'
  return <Comp {...rest} />
}
 
<Button>basic</Button>
<Button as="a" href="/x">link</Button>      // <a href>
<Button as={Link} to="/x">router</Button>   // <Link to>

타입은 동작하지만 세 가지 함정이 동시에 터진다:

  1. forwardRef와 generic이 충돌한다. forwardRef<HTMLButtonElement>를 generic으로 일반화하려면 강력한 캐스팅이 필요하다.
  2. ref 타입이 잘못된다: as="a"인데 ref<HTMLButtonElement>를 받는 식.
  3. 타입 추론이 무거워진다: tsserver가 느려지고, 에러 메시지가 100줄짜리 generic으로 변한다.

이 패턴을 만들었던 라이브러리들(Chakra v1, MUI styled API)이 공식적으로 후회를 표명한 이력이 있다.

asChild + Slot — Radix가 정착시킨 표준

import { Slot } from '@radix-ui/react-slot'
 
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({ asChild, className, ...props }, ref) => {
  const Comp = asChild ? Slot : 'button'
  return <Comp ref={ref} className={cn(buttonStyles(), className)} {...props} />
})

사용:

<Button>plain</Button>
<Button asChild>
  <a href="/dashboard">대시보드</a>
</Button>
<Button asChild>
  <Link to="/dashboard">router link</Link>
</Button>

핵심은 Slot이다. Slot자식 element 하나를 받아 그 element에 props/className/ref를 머지한다. React의 cloneElement를 기반으로 한다.

// Slot의 본질 (의사 코드)
function Slot({ children, ...slotProps }) {
  return React.cloneElement(React.Children.only(children), {
    ...slotProps,
    ...children.props,
    className: cn(slotProps.className, children.props.className),
    ref: composeRefs(slotProps.ref, children.ref),
  })
}

이게 왜 이기는가:

  • as prop이 외부에서 어떤 ElementType을 받을지 모름 → 타입 generic이 필요.
  • asChild외부가 이미 element를 넘김 → 타입은 그냥 boolean 하나.
  • ❌ wrapper <button><a></a></button> 없음. invalid HTML 회피.
  • ✅ 자식 element가 그대로 DOM에 존재하므로 시맨틱이 정확.

What — 구체 사양

@radix-ui/react-slot의 API

import { Slot, Slottable } from '@radix-ui/react-slot'
 
// 단일 자식 머지
<Slot className="btn">
  <a href="/x">link</a>
</Slot>
// 결과: <a href="/x" className="btn">link</a>
 
// 자식 사이에 슬롯이 아닌 콘텐츠가 섞여 있을 때 — Slottable
<Slot className="btn">
  <a href="/x">
    <Slottable>link</Slottable>
    <ChevronIcon />
  </a>
</Slot>

우리 Button의 완전한 구현

import { forwardRef, type ButtonHTMLAttributes } from 'react'
import { Slot } from '@radix-ui/react-slot'
import { button } from 'styled-system/recipes'
import type { ButtonVariantProps } from 'styled-system/recipes'
 
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
  ButtonVariantProps & {
    asChild?: boolean
  }
 
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ asChild = false, variant, size, className, ...props }, ref) => {
    const Comp = asChild ? Slot : 'button'
    return (
      <Comp
        ref={ref}
        className={cn(button({ variant, size }), className)}
        {...props}
      />
    )
  }
)
Button.displayName = 'Button'

사용 시나리오 표

시나리오코드DOM 결과
일반 버튼<Button>로그인</Button><button class="...">로그인</button>
링크 버튼<Button asChild><a href="/x">go</a></Button><a href="/x" class="...">go</a>
라우터 링크<Button asChild><Link to="/x">go</Link></Button><a href="/x" class="...">go</a> (Link 구현에 따름)
비활성화 링크<Button asChild disabled><a>x</a></Button><a aria-disabled class="...">x</a> (handler 차단은 별도)
외부 className 합성<Button asChild className="ml-2"><a/></Button>className이 머지됨

compound (asChild + Slottable + icon)

<Button asChild>
  <a href="/x">
    <Icon name="chevron-left" />
    <Slottable>뒤로</Slottable>
  </a>
</Button>

<a> 안에 icon + 텍스트가 그대로. wrapper 없음.


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

  • 함정 1 — asChild가 자식을 하나만 받는다는 사실을 잊음

    • 증상: <Button asChild><Icon/><span>x</span></Button>React.Children.only 에러.
    • 대응: 자식을 하나의 element로 감싸라. <Button asChild><a><Icon/><span>x</span></a></Button>.
  • 함정 2 — forwardRef를 안 쓴 자식

    • 증상: <Button asChild><MyLink to="/x" /></Button>에서 ref가 자식까지 도달하지 않음. focus 관리, tooltip 등이 깨짐.
    • 대응: MyLinkforwardRef로 정의. 또는 React 19+의 ref-as-prop 활용.
  • 함정 3 — className의 우선순위

    • 증상: <Button asChild className="px-8"><a className="px-2">x</a></Button> → 어느 padding이 이기는가?
    • 대응: Slot은 두 className을 단순 concat한다. 우선순위는 CSS 소스 순서. Tailwind를 쓴다면 tailwind-merge 또는 cn() 유틸로 머지하라. Panda는 cx().
  • 함정 4 — 이벤트 핸들러가 두 곳에 있을 때

    • 증상: <Button onClick={a} asChild><button onClick={b}/></Button> — Slot은 둘 다 호출한다 (자식 핸들러를 부모 핸들러 다음에).
    • 대응: 의도된 동작이지만 알고 써야 한다. 명시적으로 한 쪽에만 두는 게 안전.
  • 함정 5 — polymorphic as prop으로 회귀

    • 증상: 팀원이 “MUI에서는 as로 했는데”라며 추가 요청.
    • 대응: as prop의 타입 폭발 사례를 문서로 남기고, asChild를 컨벤션으로 못 박는다.
  • 함정 6 — Next.js <Link> 통합의 두 시대

    • Next 12 이하: <Link><a /></Link> 패턴 → <Button asChild><Link><a>x</a></Link></Button> 필요.
    • Next 13+: <Link> 자체가 anchor 렌더 → <Button asChild><Link href="/x">x</Link></Button> 단순.

Insight — Slot은 왜 React에 표준으로 안 들어왔나

흥미롭게도 React 팀은 cloneElement를 권장하지 않는다. 공식 문서는 “props를 직접 전달하라”고 말한다. 그런데도 Slot이 사실상 표준이 된 이유는:

“디자인 시스템 측은 이미 컴포넌트의 시각을 책임지고 있고, 사용자는 시맨틱(태그)을 책임진다. 그 두 책임의 합성점이 cloneElement뿐이다.”

Radix Primitives 팀(Jenna Smith, Pedro Duarte 등)은 2021년경 asChild 패턴을 정착시켰고, 2022년 shadcn/ui가 폭발적으로 인기를 끌면서 이 패턴은 React 생태계의 사실상 표준이 되었다. 현재 Headless UI, Ariakit, Reka UI (Vue), Melt UI (Svelte) 모두 비슷한 API를 채택했다.

부가 효과: asChild 덕분에 디자인 시스템이 라우터를 모르게 되었다. Button은 그저 자식 element에 스타일을 입힐 뿐, 그게 <a>이든 <Link>이든 <NavLink>이든 신경 쓰지 않는다. 관심사의 분리가 깔끔하게 달성된 보기 드문 사례다.


요약

  • 같은 디자인을 다른 태그로 렌더하는 두 길 — as prop과 asChild.
  • as prop은 타입 폭발 + forwardRef 지옥으로 사실상 폐기.
  • asChild + @radix-ui/react-slot이 사실상 표준 (shadcn/ui, Radix, Headless UI 모두 채택).
  • 자식 하나만 받기, forwardRef 챙기기, className 머지 (tailwind-merge/cx)는 필수 점검 항목.
  • 라우터 라이브러리 통합이 깔끔해지는 부가 효과. 디자인 시스템이 라우터를 모르게 된다.