tailwind에서 재사용 가능한 button 컴포넌트 만들기 (with class-variance-authority)

씨네톡(영화 리뷰 서비스) 프로젝트에서는 반응형 디자인을 제공하기 때문에 다양한 크기의 버튼 디자인이 있습니다.

위 디자인 가이드의 경우 size와 varint 두 개의 객체로 분리한 후 size에는 크기 관련 클래스를, varint 객체에는 색상 관련 클래스를 넣어 객체 맵핑으로 간결하게 코드를 작성할 수 있습니다.

import { ButtonHTMLAttributes } from 'react';
 
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
  size: 'XL' | 'L' | 'M' | 'S' | 'XS';
  variant: 'bg-orange' | 'bg-gray';
  children: React.ReactNode;
}
const SIZE_MAP: { [k in Props['size']]: string } = {
  XL: 'w-[360px] rounded-xl py-3 px-5 Text-m-Medium',
  L: 'w-[180px] rounded-xl py-3 px-5 Text-m-Medium',
  M: 'w-fit rounded-lg py-2 px-4 Text-m-Medium',
  S: 'w-fit rounded-lg py-1 px-3 Text-s-Medium',
  XS: 'w-fit rounded-md py-1 px-2 Text-xs-Regular',
};
const VARIANT_MAP: { [k in Props['variant']]: string } = {
  'bg-gray':
    'bg-D2_Gray text-Gray hover:bg-D3_Gray hover:text-Gray_Orange active:bg-Gray active:text-Silver disabled:bg-D2_Gray disabled:text-Gray',
  'bg-orange':
    'bg-Primary text-Silver hover:bg-Shade_1 active:bg-Shade_3 disabled:bg-D2_Gray disabled:text-Gray',
};
 
export default function Button({ size, variant, children, ...rest }: Props) {
  return (
    <button
      className={`flex justify-center ${SIZE_MAP[size]} ${VARIANT_MAP[variant]}`}
      {...rest}
    >
      {children}
    </button>
  );
}

BG 버튼 외에 또 다른 버튼들이 존재하는데요.

Line 버튼의 경우 사이즈는 L, M, S 3개의 사이즈를 제공하는데 문제는 BG Button과는 사이즈가 다릅니다. 또한 Text 버튼과 Icon 버튼도 독자적인 사이즈를 갖고 있습니다. 그렇다면 SIZE_MAP의 클래스를 사용할 수 없게 되는데요. 이제 고민에 빠지게 됩니다. 각각의 버튼 컴포넌트를 만들 것인지. 하나만 작성하되 클린하게 코드를 작성할 수는 없는지. 저는 팀원들이 하나의 컴포넌트 사용으로 편하게 버튼 컴포넌트를 사용하게 하고 싶은데요.
이러한 고민 끝에 class-variance-authority (이하 cva)를 도입하기로 합니다. cva는 이러한 고민을 해결해주며 타입스크립트를 지원하고 있습니다.

npm i class-variance-authority

cva는 cva함수를 사용하여 클래스를 정의하는데요. 첫 번째 인수에는 공통 class를 넣어줍니다. 두 번째 인수는 객체를 받는데요. variants 객체에는 기본 구성 요소를, compoundVariants 객체에는 해당 변수들의 조건이 충족될 때 적용될 클래스를 넣어줍니다.

import { cva, VariantProps } from 'class-variance-authority';
import { ButtonHTMLAttributes } from 'react';
 
interface Props
  extends ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof ButtonVariants> {
  children: React.ReactNode;
}
 
const ButtonVariants = cva(`flex justify-center whitespace-nowrap`, {
  variants: {
    variant: {
      grey: 'bg-D2_Gray text-Gray hover:bg-D3_Gray hover:text-Gray_Orange active:bg-Gray active:text-Silver disabled:bg-D2_Gray disabled:text-Gray',
      orange:
        'bg-Primary text-Silver hover:bg-Shade_1 active:bg-Shade_3 disabled:bg-D2_Gray disabled:text-Gray',
      line: 'inner-gray bg-transparent text-Gray_Orange hover:inner-silver hover:text-Silver active:bg-D1_Gray',
    },
    size: {
      XL: 'w-[360px] rounded-xl py-3 px-5 Text-m-Medium',
      L: 'w-[180px] rounded-xl py-3 px-5 Text-m-Medium',
      M: 'w-fit rounded-lg py-2 px-4 Text-m-Medium',
      S: 'w-fit rounded-lg py-1 px-3 Text-s-Medium',
      XS: 'w-fit rounded-md py-1 px-2 Text-xs-Regular',
    },
  },
  compoundVariants: [
    {
      variant: 'line',
      size: 'L',
      className: 'py-3 px-5 w-[180px]',
    },
    {
      variant: 'line',
      size: 'M',
      className: 'py-3 px-5 w-[120px]',
    },
    {
      variant: 'line',
      size: 'S',
      className: 'py-3 px-5 w-fit',
    },
  ],
});
 
export default function Button({ size, variant, children, ...rest }: Props) {
  return (
    <button className={ButtonVariants({ size, variant })} {...rest}>
      {children}
    </button>
  );
}

compoundVariants의 경우 variant에는 line이, size는 L, M, S의 경우 해당 클래스가 적용된다로 해석됩니다. settings.json파일에 아래 정규표현식 코드를 추가하면 객체에 클래스를 입력할 때에도 Tailwind CSS IntelliSense가 적용됩니다.

{
  "tailwindCSS.experimental.classRegex": [
    ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
    ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
  ]
}

완성된 Button 컴포넌트는 아래와 같이 사용할 수 있습니다.

return (
  <Button size="S" variant="line" onClick={handleButtonClick}>
    로그인
  </Button>
);