Vanilla Extract로 디자인 시스템을 구축하면서 sprinkles를 활용해 디자인 토큰을 적용하는 방식이 반복적이라는 문제를 느꼈다. 매번 css.ts 파일을 생성해야 했고, Tailwind처럼 props 기반으로 스타일을 적용할 수 있다면 더 효율적이지 않을까 하는 고민이 들었다.
이 과정에서 스타일을 props로 전달하면서도 확장성이 높은 방식을 고민하게 되었고, 이런 문제를 해결하려고 여러 UI 라이브러리들의 패턴을 찾아보다가 Polymorphic 컴포넌트라는 재미있는 개념을 발견했다.
Box 컴포넌트를 활용하면 as prop을 통해 다양한 HTML 요소를 렌더링하면서도, sprinkles 기반으로 디자인 토큰을 props로 전달할 수 있었다.
이를 통해 Vanilla Extract의 디자인 토큰을 더 유연하고 타입 안전하게 적용할 수 있을 것이라 생각했다.
실제로 다음과 같은 Headless UI 라이브러리에서 Polymorphic 컴포넌트를 활용하고 있었다.
그렇다면 Polymorphic 컴포넌트란 무엇일까?
Polymorphic 컴포넌트란?
Polymorphic이란 여러 개의 형태를 가진다 라는 의미를 가진다.
리액트에서 Polymorphic 컴포넌트란 전달받은 props에 따라 다른 HTML 요소나 컴포넌트로 렌더링될 수 있는 컴포넌트를 말한다.
즉, 어떤 컴포넌트로든 변할 수 있는 가장 추상화된 형태의 컴포넌트라고 볼 수 있다.
<Box as="h1">제목</Box>
<Box as="p">문단</Box>
<Box as="button">버튼</Box>
✔ 하나의 컴포넌트로 다양한 요소를 표현
→ <h1>, <p>, <button> 등 여러 요소를 동일한 스타일로 재사용 가능
✔ 불필요한 코드 반복 최소화
→ 같은 스타일을 여러 곳에서 중복 작성할 필요 없이, 일관된 컴포넌트로 관리
✔ 유연한 스타일 확장
→ 시맨틱한 HTML 구조를 유지하면서도 디자인 시스템에 맞게 스타일 적용 가능
기본적인 Polymorphic 컴포넌트
가장 간단한 Polymorphic 컴포넌트는 as prop을 활용하여 요소를 변경하는 방식이다.
const Box = <C extends ElementType = 'div'>({ as, ...props }: { as?: C }) => {
const Component = as || 'div';
return <Component {...props} />;
};
<Box as="button" onClick={() => alert('클릭!')}>버튼</Box>
이 방식은 간단하지만 타입 안전성이 부족하다.
Box as="button"일 때 onClick을 지원해야 하지만, 현재 타입 시스템에서는 보장되지 않는다.
이를 해결하기 위해 Polymorphic 타입을 정의해야 한다.
Polymorphic 타입 구현하기
Type-safe한 Polymorphic 타입을 구현을 하려면 다음과 같은 요소를 고려해야한다.
asprop에 따라 올바른 HTML 요소로 변경되어야 한다.- 해당 요소의 기본 속성도 지원해야 한다.
- ex)
button사용 시onClick을 허용해야 함
- ex)
ref가 정상적으로 동작해야 한다.asprop이 변경되더라도ref가 올바른 요소에 연결되어야 함
- TypeScript에서
asprop에 맞는 속성만 허용해야 한다.
기본 타입 import 하기
먼저 필요한 React 타입들을 import 한다.
import type {
ComponentPropsWithRef,
ComponentPropsWithoutRef,
ElementType,
PropsWithChildren,
} from 'react';이 타입들이 왜 필요한지 살펴보면
ElementType: HTML 태그나 React 컴포넌트 타입을 나타냄ComponentPropsWithRef: ref를 포함한 컴포넌트의 props 타입ComponentPropsWithoutRef: ref를 제외한 컴포넌트의 props 타입PropsWithChildren: children prop을 포함한 타입
PolymorphicRef 타입 정의
가장 먼저 ref 타입을 정의한다.
export type PolymorphicRef<C extends ElementType> = ComponentPropsWithRef<C>['ref'];// 버튼의 ref 타입
type ButtonRef = PolymorphicRef<'button'>;
// div의 ref 타입
type DivRef = PolymorphicRef<'div'>;
// 실제 사용
const buttonRef: ButtonRef = useRef<HTMLButtonElement>(null);어떤 HTML 엘리먼트의 ref 타입을 추출하기 위해 필요한 타입이다.
AsProp 타입 정의
다음으로 as prop의 타입을 정의한다.
export type AsProp<C extends ElementType> = {
as?: C;
};as prop이 선택적이면서 HTML 엘리먼트나 React 컴포넌트 타입만 받을 수 있도록 제한한다.
이 as props 타입을 통해 HTML 요소를 변경할 수 있다. 간단하지만 중요한 타입이다.
사용 예시
// 버튼으로 변환 가능한 prop 타입
type ButtonAsProp = AsProp<'button'>;
// { as?: 'button' }
// 실제 컴포넌트에서
const Component = ({ as: Tag = 'div' }: AsProp<'div' | 'button'>) => {
return <Tag>내용</Tag>;
};PropsToOmit 유틸리티 타입 정의
컴포넌트에서 중복되는 prop 키들을 제거하기 위한 유틸리티 타입을 만든다.
제거해야 할 prop들의 키
export type PropsToOmit<C extends ElementType, P> = keyof (AsProp<C> & P);사용 예시
// 1. HTML 버튼이 기본적으로 가지고 있는 props
interface HTMLButtonProps {
onClick: (e: MouseEvent) => void;
className: string;
type: 'submit' | 'button' | 'reset';
disabled?: boolean;
// ... 기타 props
}
// 2. 우리가 만들고 싶은 커스텀 버튼 props
type CustomButtonProps = {
variant: 'primary' | 'secondary'; // 새로운 prop
onClick: () => void; // ⚠️ HTML 버튼과 중복
className: string; // ⚠️ HTML 버튼과 중복
}type ButtonPropsToOmit = PropsToOmit<'button', CustomButtonProps>;
// 결과: "as" | "onClick" | "className" | "variant"즉 PropsToOmit은 어떤 props를 HTML 버튼에서 빼고 우리 것을 쓸지 알려주는 타입이다.
PolymorphicComponentProp
이제 이 PropsToOmit 과 AsProp을 활용하여 PolymorphicComponentProp을 만든다.
ref가 없는 기본 polymorphic 컴포넌트의 props 타입을 만든다.
export type PolymorphicComponentProp<
C extends ElementType,
Props = Record<string, unknown>
> = PropsWithChildren<Props & AsProp<C>> &
Omit<ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
PropsWithChildren<Props & AsProp<C>>:children과asprop을 포함한 propsOmit<ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>: 중복 props 제거
- 커스텀 props와 as prop을 결합
- children prop 추가
- HTML 엘리먼트의 기본 props에서 중복되는 것들을 제거
type CustomButtonProps = {
variant: 'primary' | 'secondary';
};
type ButtonComponentProps = PolymorphicComponentProp<'button', CustomButtonProps>;
PolymorphicComponentPropWithRef
기본 PolymorphicComponentProp 타입에 ref prop을 추가한다.
export type PolymorphicComponentPropWithRef<
C extends ElementType,
Props = Record<string, unknown>
> = PolymorphicComponentProp<C, Props> & { ref?: PolymorphicRef<C> };
이제 ref를 지원하는 최종 타입이 된다.
전체코드
import type {
ComponentPropsWithRef,
ComponentPropsWithoutRef,
ElementType,
PropsWithChildren,
} from 'react';
/**
* Polymorphic component의 ref 타입을 정의
* @template C - 컴포넌트의 기본 HTML 엘리먼트 타입
*/
export type PolymorphicRef<C extends ElementType> =
ComponentPropsWithRef<C>['ref'];
/**
* 'as' prop의 타입을 정의
* @template C - 변환하고자 하는 엘리먼트 타입
*/
export type AsProp<C extends ElementType> = {
as?: C;
};
/**
* 중복되는 prop 키들을 제거하기 위한 유틸리티 타입
* @template C - 컴포넌트의 기본 HTML 엘리먼트 타입
* @template P - 컴포넌트의 추가 props 타입
*/
export type PropsToOmit<C extends ElementType, P> = keyof (AsProp<C> & P);
/**
* ref가 없는 Polymorphic 컴포넌트의 props 타입
* @template C - 컴포넌트의 기본 HTML 엘리먼트 타입
* @template Props - 컴포넌트의 추가 props 타입
*/
export type PolymorphicComponentProp<
C extends ElementType,
Props = Record<string, unknown>,
> = PropsWithChildren<Props & AsProp<C>> &
Omit<ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
/**
* ref를 포함한 Polymorphic 컴포넌트의 props 타입
* @template C - 컴포넌트의 기본 HTML 엘리먼트 타입
* @template Props - 컴포넌트의 추가 props 타입
*/
export type PolymorphicComponentPropWithRef<
C extends ElementType,
Props = Record<string, unknown>,
> = PolymorphicComponentProp<C, Props> & { ref?: PolymorphicRef<C> };
최종 Box 컴포넌트 구현
모든 타입이 준비되었으니, 예시로 Polymorphic한 Box컴포넌트를 만들 수 있다.
import { PolymorphicComponentPropWithRef, PolymorphicRef } from './types'
const Box = forwardRef(<C extends ElementType = 'div'>(
props: PolymorphicComponentPropWithRef<C, { color?: string }>,
ref?: PolymorphicRef<C>
) => {
const { as: Component = 'div', color, children, ...rest } = props;
return (
<Component
ref={ref}
style={{ color }}
{...rest}
>
{children}
</Component>
);
});
이렇게 구현하면 타입 안전하고 재사용성 높은 Box Polymorphic 컴포넌트를 만들 수 있다.
// 사용
<Box>기본 텍스트</Box>
<Box as="h1" color="red">빨간 제목</Box>
<Box as="button" onClick={() => alert('클릭!')}>버튼</Box>
이제 Box 컴포넌트를 만들었으니, Vanilla Extract의 디자인 토큰을 Tailwind처럼 prop으로 활용해보자.
Vanilla Extract와 결합하여 스타일 확장하기
Polymorphic 컴포넌트와 Vanilla Extract를 결합하기 위해, Sandro Roth가 제안한 방식을 참고했다.
동작원리는 다음과 같다.
Box컴포넌트가props를 받으면extractAtoms가 스타일 관련props를 분리sprinkles가 해당props에 맞는 CSS 클래스를 생성- 생성된 클래스가 컴포넌트에 적용
이렇게 하면 Vanilla Extract의 타입 안전성은 유지하면서도, Tailwind처럼 직관적으로 스타일을 적용할 수 있다.
Sprinkles 설정하기
먼저 sprinkles 파일을 만들고 실제로는 더 많은 토큰들이 필요했지만, 예시로 기본적인 것들만 작성을 하였다.
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles';
const responsiveProperties = defineProperties({
conditions: {
mobile: {},
tablet: { '@media': 'screen and (min-width: 768px)' },
desktop: { '@media': 'screen and (min-width: 1024px)' },
},
defaultCondition: 'mobile',
properties: {
display: ['none', 'flex', 'block', 'inline'],
flexDirection: ['row', 'column'],
padding: {
none: '0',
small: '8px',
medium: '16px',
large: '24px',
},
margin: {
none: '0',
small: '8px',
medium: '16px',
large: '24px',
},
},
shorthands: {
p: ['padding'],
m: ['margin'],
}
});
const baseProperties = defineProperties({
properties: {
cursor: ['default', 'pointer'],
borderRadius: {
none: '0',
small: '4px',
medium: '8px',
large: '16px',
}
}
});
// 3. Sprinkles 함수 생성
export const sprinkles = createSprinkles(
responsiveProperties,
baseProperties
);
// 4. 타입 추출
export type Sprinkles = Parameters<typeof sprinkles>[0];
extractAtoms로 스타일 props 분리하기
atoms.ts 파일을 생성하여 Sprinkles를 활용할 수 있는 유틸 함수들을 정의한다.
const keys = Array.from(sprinkles.properties.keys());
export const extractAtoms = <P extends object>(props: P) => [
pick(props, keys),
omit(props, keys),
];
sprinkles.properties.keys()
- sprinkles에 정의된 모든 스타일 속성의 키값들을 가져온다.
- 예시코드에서
['display', 'flexDirection', 'padding', 'margin', 'cursor', 'borderRadius']를 가져올 수 있다.
extractAtoms 함수
- 컴포넌트의 props를 스타일 props와 일반 props로 분리하여 튜플 형태로 반환한다.
- 제네릭을 사용하여 어떤 타입의 props도 처리 가능하게한다.
generateClassNames 함수를 만든다.
function generateClassNames(atoms: Atoms) {
const { reset, className, ...rest } = atoms;
const sprinklesClassNames = sprinkles(rest);
return clsx(
sprinklesClassNames,
className,
reset ? [elementResets[reset]] : null,
);
}
reset: HTML 요소의 기본 스타일을 초기화하는 키 (예: 'button', 'input')className: 사용자가 직접 전달한 커스텀 클래스rest: 나머지 스타일 속성들 (sprinkles로 처리될 속성들)
const sprinklesClassNames = sprinkles(rest)로 atomic 클래스들을 생성한다.
clsx로 클래스 결합
return clsx(
sprinklesClassNames, // sprinkles로 생성된 클래스들
className, // 사용자 정의 클래스
reset ? [elementResets[reset]] : null // 리셋 클래스 (있는 경우)
);
generateClassNames({
reset: 'button',
className: 'custom-button',
padding: 'medium',
margin: 'large'
});
// 결과: 'sprinkles_padding_medium sprinkles_margin_large custom-button button-reset'
atoms 함수
export function atoms(atoms: Atoms) {
return generateClassNames(atoms);
}
- 내부 구현(
generateClassNames)을 숨기고 단순한 공개 API를 제공한다. 캡슐화 - 타입 안전성을 보장하면서 클래스 이름을 생성한다.
전체코드
import { elementResets, sprinkles } from '../styles';
import clsx from 'clsx';
import omit from 'lodash/omit';
import pick from 'lodash/pick';
import type { Atoms } from '../types/atoms';
const keys = Array.from(sprinkles.properties.keys());
export const extractAtoms = <P extends object>(props: P) => [
pick(props, keys),
omit(props, keys),
];
function generateClassNames(atoms: Atoms) {
const { reset, className, ...rest } = atoms;
const sprinklesClassNames = sprinkles(rest);
return clsx(
sprinklesClassNames,
className,
reset ? [elementResets[reset]] : null,
);
}
export function atoms(atoms: Atoms) {
return generateClassNames(atoms);
}
Box 컴포넌트에 Sprinkles 적용하기
이제 polymorphic 한 컴포넌트에서 도입을 해보자.
import {
type ElementType,
type HTMLAttributes,
type ReactNode,
forwardRef,
} from 'react';
import type { Atoms } from './atoms';
import type { PolymorphicComponentPropWithRef } from './types/polymorhpic';
import { atoms, extractAtoms } from './utils/atoms';
type HTMLProperties = Omit<
HTMLAttributes<HTMLElement>,
'className' | 'color' | 'height' | 'width' | 'size'
>;
export type BoxProps<C extends ElementType = 'div'> =
PolymorphicComponentPropWithRef<C, HTMLProperties & Atoms>;
type BoxComponent = <C extends ElementType = 'div'>(
props: BoxProps<C>,
) => ReactNode;
export const Box: BoxComponent = forwardRef<HTMLElement, BoxProps<ElementType>>(
({ as, ...restProps }, ref) => {
const [atomsProps, propsToForward] = extractAtoms(restProps);
const Component: ElementType = as || 'div';
const className = atoms({
className: propsToForward?.className,
reset: typeof Component === 'string' ? Component : 'div',
...atomsProps,
});
return <Component {...propsToForward} className={className} ref={ref} />;
},
);
코드를 하나씩 살펴보면
type HTMLProperties = Omit<
HTMLAttributes<HTMLElement>,
'className' | 'color' | 'height' | 'width' | 'size'
>;
HTML 기본 속성에서 Atoms와 충돌할 수 있는 속성들 제외
Box Props 타입 정의
export type BoxProps<C extends ElementType = 'div'> =
PolymorphicComponentPropWithRef<C, HTMLProperties & Atoms>;
- HTML 속성과 Atoms 속성을 결합
- ref 타입도 포함
타입 시스템 잡기
여기서 첫 번째 난관에 부딪혔다. 타입 시스템을 제대로 잡지 않으면 props 자동완성이 안 되는 문제였다.
특히 BoxComponent 타입을 명시적으로 정의하는 게 중요했다
BoxComponent의 타입을 지정을 안해주면 props 자동완성이 되지않는다.
// 1. BoxComponent 타입 정의
type BoxComponent = <C extends ElementType = 'div'>(
props: BoxProps<C>,
) => ReactNode;
// 2. Box 컴포넌트에 타입 어노테이션 적용
export const Box: BoxComponent = forwardRef(
<C extends ElementType = 'div'>(
{ as, ...restProps }: BoxProps<C>,
ref?: PolymorphicRef<C>
) => {
// ... 구현
}
);
이렇게하면 제네릭 타입을 보존할 수 있다.
// BoxComponent 타입이 없다면:
const Box = forwardRef(...)
// ⚠️ 제네릭이 특정 타입으로 고정됨
// props의 타입 추론이 제대로 되지 않음
// BoxComponent 타입이 있으면:
const Box: BoxComponent = forwardRef(...)
// ✅ 제네릭 타입이 보존됨
// props의 타입이 정확하게 추론됨
Sprinkles Props 타입 추론
// BoxComponent 타입이 없을 때:
<Box
padding={} // ⚠️ 자동완성 동작 안함
margin={} // ⚠️ 가능한 값 추론 안됨
/>
// BoxComponent 타입이 있을 때:
<Box
padding="medium" // ✅ 자동완성으로 가능한 값 표시
margin={['small', 'medium']} // ✅ 반응형 값도 정확히 추론
/>
as prop 타입 추론
// BoxComponent 타입이 있을 때의 정확한 타입 추론:
<Box as="button" onClick={() => {}} /> // ✅ button의 props 정확히 추론
<Box as="a" href="/home" /> // ✅ a 태그의 props 정확히 추론
<Box as={CustomComponent} customProp="" /> // ✅ 커스텀 컴포넌트 props 추론
// ✅ 모든 props 타입이 정확하게 추론됨
<Box
as="button"
padding="medium"
margin={['small', 'medium']}
display={{ mobile: 'block', desktop: 'flex' }}
onClick={(e: React.MouseEvent) => {}}
>
버튼
</Box>
// ✅ 커스텀 컴포넌트 props도 정확하게 추론
interface CustomProps {
custom: string;
}
const Custom = ({ custom }: CustomProps) => <div>{custom}</div>;
<Box
as={Custom}
custom="value" // ✅ CustomProps 타입 체크
padding="medium"
>
커스텀
</Box>
BoxComponent의 타입을 명시적으로 정의해야 아래와 같은 타입 추론이 동작을 한다.
- 제네릭 타입 보존
- Sprinkles props 타입 추론
- as prop에 따른 정확한 props 타입 추론
- DX를 향상 (자동완성, 타입 체크)
Box를 활용한 Button 컴포넌트 확장
import { type ButtonHTMLAttributes, type ReactNode, forwardRef } from 'react';
import ClipLoader from 'react-spinners/ClipLoader';
import { Box } from '..';
import type { AtomProps } from './types/atoms';
import * as styles from './Button.css';
// 1. Button 전용 Props 타입 정의
export interface ButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'prefix' | 'color'>,
AtomProps {
variant?: 'primary' | 'secondary' | 'ghost' | 'outline';
size?: 'sm' | 'md' | 'lg' | 'zero';
selected?: boolean;
prefix?: ReactNode;
suffix?: ReactNode;
loading?: boolean;
}
// 2. Button 컴포넌트 구현
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
size = 'md',
selected = false,
disabled = false,
prefix,
suffix,
loading = false,
children,
...restProps
},
ref,
) => {
return (
<Box // Box를 button 엘리먼트로 변경
as='button'
className={styles.button({
variant,
size,
disabled,
loading,
selected,
})}
disabled={disabled || loading}
aria-pressed={selected}
aria-busy={loading}
ref={ref}
{...restProps}
>
{loading && <ClipLoader color={'#0142C0'} size={14} role='status' />}
{prefix && prefix}
{children}
{suffix && suffix}
</Box>
);
},
);
이렇게 구현한 Button 컴포넌트는
Box의 모든 스타일 props를 그대로 사용할 수 있다Button고유의 기능(loading, prefix/suffix 등)을 추가했다- 완벽한 타입 안전성을 유지한다
- aria 속성들도 자동으로 처리된다

마치며
적용 후 달라진 점들
Polymorphic 컴포넌트 패턴을 도입하면서 여러 가지 긍정적인 변화가 있었다.
- 가장 먼저 체감된 것은 개발 경험의 개선이었다. 코드가 훨씬 직관적이게 되었고, 타입스크립트의 강력한 타입 시스템 덕분에 안정성도 확보할 수 있었다. 특히 컴포넌트의 재사용성이 높아져서 새로운 기능을 추가할 때 기존 코드를 많이 활용할 수 있었다.
- 디자인 시스템 측면에서도 큰 이점이 있었다. 디자인 토큰을 일관되게 사용할 수 있게 되었고, Box 컴포넌트를 기반으로 새로운 컴포넌트를 쉽게 추가할 수 있었다. 무엇보다 스타일 확장이 유연해져서 디자인 변경 요청에도 빠르게 대응할 수 있었다.
- 결과적으로 전반적인 생산성이 크게 향상됐다. 반복적인 코드 작성이 줄었고, 컴포넌트 개발 시간도 단축됐다. 특히 유지보수가 쉬워져서 장기적으로 봤을 때 더 큰 가치가 있었다.
남은 과제들
물론 아직 개선의 여지가 많이 남아있다. 앞으로는 다음과 같은 부분들에 집중할 계획이다:
- 접근성(a11y) 강화: ARIA 속성 자동화와 키보드 인터랙션 개선으로 더 나은 사용자 경험을 제공하고자 한다
- 성능 최적화: 번들 사이즈와 렌더링 성능을 개선하여 더 빠른 로딩 속도를 확보할 것이다
- 문서화 개선: Storybook을 통합하고 상세한 사용 가이드를 작성하여 팀 내 활용도를 높일 예정이다
- 테스트 강화: 단위 테스트와 E2E 테스트를 확대하여 안정성을 더욱 높여갈 것이다
Polymorphic 컴포넌트 구현이 쉽지는 않았지만, 그만큼 배운 것도 많았다. 특히 타입스크립트의 깊이 있는 활용과 컴포넌트 설계의 중요성을 실감했다.
처음에는 단순히 Vanilla Extract를 Tailwind처럼 편하게 쓰고 싶다는 생각으로 시작했는데, 결과적으로 더 큰 가치를 발견했다. 특히 디자인 시스템을 구축하는 과정에서 이 패턴이 정말 유용했다. 컴포넌트의 재사용성도 높아지고, 개발 생산성도 크게 향상됐다.
비슷한 고민을 하고 있는 개발자들에게 이 경험이 작은 도움이 되길 바란다. 더 나은 디자인 시스템을 만들기 위한 여정은 계속될 것이다.
참고자료
