Skip to content
Implementing Polymorphic Components

31 Aug 2024 · 14 min read

Implementing Polymorphic Components

With Vanilla Extract

Frontend

Vanilla Extract While building a design system with Vanilla Extract, I noticed that applying design tokens using sprinkles was repetitive. Each time, css.ts file had to be created, and Tailwind I wondered if applying styles based on props, like Tailwind, would be more efficient.

In this process, I started thinking about a highly extensible way to pass styles as props, and while looking for patterns in various UI libraries to solve this problem, Polymorphic 컴포넌트I discovered an interesting concept called.

Box By utilizing the component, as I was able to render various HTML elements via the prop while also passing design tokens as props based on sprinkles.

Through this, I believed I could apply Vanilla Extract's design tokens more flexibly and type-safely.

In fact, Headless UI libraries like the following were already utilizing Polymorphic components.

So, Polymorphic what exactly is a component?

What is a Polymorphic Component?

Polymorphic means having multiple forms.

In React, a Polymorphic component refers to a component that can be rendered as different HTML elements or components depending on the props it receives.

In other words, it can be seen as the most abstracted form of a component that can transform into any other component.

<Box as="h1">제목</Box>
<Box as="p">문단</Box>
<Box as="button">버튼</Box>

✔ Expressing various elements with a single component

→ <h1>, <p>, <button> and other elements can be reused with the same style

✔ Minimize unnecessary code repetition

→ No need to write the same styles redundantly in multiple places, managed with a consistent component

✔ Flexible style extension

→ Maintain a semantic HTML structure while applying styles according to the design system

Basic Polymorphic Component

The simplest Polymorphic component changes elements by utilizing the 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>

This method is simple, but lacks type safety.

Box as="button" should support onClick when used, but it's not guaranteed by the current type system.

To solve this, Polymorphic types must be defined.

Implementing Polymorphic Types

To implement type-safe Polymorphic types, the following factors must be considered.

  • as prop should correctly change the HTML element.
  • It must also support the default properties of that element.
    • e.g., button should allow onClick when used
  • ref must function correctly.
    • as Even if the prop changes, ref should be correctly connected to the element
  • In TypeScript, as should only allow attributes that match the prop.

Importing Base Types

First, import the necessary React types.

import type {
  ComponentPropsWithRef,
  ComponentPropsWithoutRef,
  ElementType,
  PropsWithChildren,
} from 'react';

Let's look at why these types are needed:

  • ElementType: Represents HTML tag or React component types
  • ComponentPropsWithRef: Props type of a component, including ref
  • ComponentPropsWithoutRef: Props type of a component, excluding ref
  • PropsWithChildren: Type including the children prop

Defining PolymorphicRef Type

First, define the ref type.

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);

This type is necessary to extract the ref type of any HTML element.

Defining AsProp Type

Next, define the type of the as prop.

export type AsProp<C extends ElementType> = {
  as?: C;
};

as prop is optional and restricted to only accept HTML element or React component types.

Through this as props type, HTML elements can be changed. It's a simple but important type.

Usage Example

// 버튼으로 변환 가능한 prop 타입
type ButtonAsProp = AsProp<'button'>;
// { as?: 'button' }

// 실제 컴포넌트에서
const Component = ({ as: Tag = 'div' }: AsProp<'div' | 'button'>) => {
  return <Tag>내용</Tag>;
};

Defining PropsToOmit Utility Type

Create a utility type to remove duplicate prop keys in a component.

Keys of props to omit

export type PropsToOmit<C extends ElementType, P> = keyof (AsProp<C> & P);

Usage Example

// 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"

In other words, PropsToOmit is a type that tells us which props to exclude from an HTML button and use our own.

PolymorphicComponentProp

Now, using PropsToOmit and AsProp, we create PolymorphicComponentProp.

Create the props type for a basic polymorphic component without ref.

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 and as prop including props
  • Omit<ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>: Remove duplicate props
  1. Combine custom props and as prop
  2. Add children prop
  3. Remove duplicates from HTML element's default props
type CustomButtonProps = {
  variant: 'primary' | 'secondary';
};
type ButtonComponentProps = PolymorphicComponentProp<'button', CustomButtonProps>;

PolymorphicComponentPropWithRef

Add the ref prop to the base PolymorphicComponentProp type.

export type PolymorphicComponentPropWithRef<
  C extends ElementType,
  Props = Record<string, unknown>
> = PolymorphicComponentProp<C, Props> & { ref?: PolymorphicRef<C> };

Now, it becomes the final type that supports ref.

Full Code

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> };

Final Box Component Implementation

Now that all types are ready, we can create a Polymorphic Box component as an example.

import { PolymorphicComponentPropWithRef, PolymorphicRef } from './types'

const Box = forwardRef(<C extends ElementType = 'div'>(
  props: PolymorphicComponentPropWithRef<C, { color?: string } accelerators
  ref?: PolymorphicRef<C>
) => {
  const { as: Component = 'div', color, children, ...rest } = props;

  return (
    <Component
      ref={ref}
      style={{ color }}
      {...rest}
    >
      {children}
    </Component>
  );
});

This implementation allows us to create a type-safe and highly reusable Box Polymorphic component.

// 사용
<Box>기본 텍스트</Box>
<Box as="h1" color="red">빨간 제목</Box>
<Box as="button" onClick={() => alert('클릭!')}>버튼</Box>

Now that we've created the Box component, let's try using Vanilla Extract's design tokens as props, similar to Tailwind.

Extending Styles by Combining with Vanilla Extract

To combine Polymorphic components with Vanilla Extract, I referred to the method proposed by Sandro Roth.

The operating principle is as follows:

  1. When the Box component receives props
  2. extractAtoms separates style-related props
  3. sprinkles generates CSS classes corresponding to those props
  4. The generated classes are applied to the component

This way, while maintaining Vanilla Extract's type safety, styles can be applied intuitively, like Tailwind.

Configuring Sprinkles

First, I created a sprinkles file. Although more tokens were needed in reality, I only wrote the basic ones for this example.

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];

Separating style props with extractAtoms

Create an atoms.ts file to define utility functions that can utilize 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()

  • Retrieves all style property keys defined in sprinkles.
  • In the example code, ['display', 'flexDirection', 'padding', 'margin', 'cursor', 'borderRadius'] can be retrieved.

extractAtoms function

  • Separates component props into style props and general props, returning them as a tuple.
  • Uses generics to handle props of any type.

Create the generateClassNames function.

function generateClassNames(atoms: Atoms) {
  const { reset, className, ...rest } = atoms;
  const sprinklesClassNames = sprinkles(rest);
  return clsx(
    sprinklesClassNames,
    className,
    reset ? [elementResets[reset]] : null,
  );
}
  • reset: Key to reset the default styles of an HTML element (e.g., 'button', 'input')
  • className: Custom class directly passed by the user
  • rest: Remaining style properties (properties to be handled by sprinkles)

const sprinklesClassNames = sprinkles(rest) generates atomic classes.

Combining classes with 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 function

export function atoms(atoms: Atoms) {
  return generateClassNames(atoms);
}
  • Hides the internal implementation (generateClassNames) and provides a simple public API. Encapsulation
  • Generates class names while ensuring type safety.

Full Code

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);
}

Applying Sprinkles to the Box Component

Now, let's introduce it in a polymorphic component.


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} />;
	},
);

Let's examine the code piece by piece:

type HTMLProperties = Omit<
  HTMLAttributes<HTMLElement>,
  'className' | 'color' | 'height' | 'width' | 'size'
>;

Exclude attributes that may conflict with Atoms from HTML base properties

Box Props Type Definition

export type BoxProps<C extends ElementType = 'div'> =
  PolymorphicComponentPropWithRef<C, HTMLProperties & Atoms>;
  • Combine HTML attributes and Atoms attributes
  • Includes ref type

Managing the Type System

Here, I encountered the first challenge. If the type system is not properly managed, props auto-completion doesn't work.

It was especially important to explicitly define the BoxComponent type.

If the type of BoxComponent is not specified, auto-completion for props will not work.

// 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>
  ) => {
    // ... 구현
  }
);

This way, generic types can be preserved.

// BoxComponent 타입이 없다면:
const Box = forwardRef(...)
// ⚠️ 제네릭이 특정 타입으로 고정됨
// props의 타입 추론이 제대로 되지 않음

// BoxComponent 타입이 있으면:
const Box: BoxComponent = forwardRef(...)
// ✅ 제네릭 타입이 보존됨
// props의 타입이 정확하게 추론됨

Sprinkles Props Type Inference

// BoxComponent 타입이 없을 때:
<Box
  padding={} // ⚠️ 자동완성 동작 안함
  margin={} // ⚠️ 가능한 값 추론 안됨
/>

// BoxComponent 타입이 있을 때:
<Box
  padding="medium" // ✅ 자동완성으로 가능한 값 표시
  margin={['small', 'medium']} // ✅ 반응형 값도 정확히 추론
/>

as prop type inference

// 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>

The type of BoxComponent must be explicitly defined for type inference like the following to work.

  1. Generic type preservation
  2. Sprinkles props type inference
  3. Accurate props type inference based on as prop
  4. Improve DX (autocomplete, type checking)

Extending Button Component using Box

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>
		);
	},
);

The Button component implemented this way:

  • Box's style props can all be used as-is
  • Button's unique features (loading, prefix/suffix, etc.) have been added
  • Maintains perfect type safety
  • ARIA attributes are also handled automatically

Conclusion

Changes after application

Adopting the Polymorphic component pattern brought about several positive changes.

  • The first thing I noticed was improved developer experience.The code became much more intuitive, and thanks to TypeScript's powerful type system, stability was also ensured. In particular, the reusability of components increased, allowing us to leverage existing code extensively when adding new features.
  • From a design system perspective,there were also significant advantages. Design tokens could be used consistently, and Box components could be easily added based on the Box component. Most importantly, style extension became flexible, allowing for quick responses to design change requests.
  • As a result, overall productivity significantly improved.Repetitive coding was reduced, and component development time was shortened. Moreover, easier maintenance proved to be of greater value in the long run.

Remaining Challenges

Of course, there is still much room for improvement. Going forward, I plan to focus on the following areas:

  • Enhance accessibility (a11y): Aim to provide a better user experience by automating ARIA attributes and improving keyboard interaction
  • Performance optimization: Will improve bundle size and rendering performance to ensure faster loading speeds
  • Documentation improvement: Plan to integrate Storybook and write detailed usage guides to increase team adoption
  • Strengthen testing: Will expand unit and E2E tests to further enhance stability

Implementing Polymorphic components was not easy, but I learned a lot from it. In particular, I realized the importance of in-depth TypeScript usage and component design.

Initially, I started with the simple idea of wanting to use Vanilla Extract as conveniently as Tailwind. As a result, I discovered greater value. This pattern was particularly useful in building the design system. Component reusability increased, and development productivity significantly improved.

I hope this experience can be of some help to developers facing similar challenges. The journey to create a better design system will continue.

References