디자인시스템 컴포넌트 중, 스타일 변형이 가장 많은 컴포넌트는 버튼 컴포넌트일 것이다. 지금 진행 중인 사이드 프로젝트에서도 다양한 방식으로 버튼 컴포넌트를 변형해서 사용하고 있다.
아래 코드처럼, 버튼의 다양한 형태를 표현하기 위해 여러 프로퍼티를 받도록 버튼 컴포넌트를 구성했다.
1import styles from './Button.module.css';23type ButtonProps = DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {4 variant: 'filled' | 'outline' | 'ghost' | 'link';5 color: 'primary' | 'neutral';6 widthType: 'fill' | 'hug';7 suffixSlot?: ReactNode;8 prefixSlot?: ReactNode;9 textAlign?: 'left' | 'center' | 'right';10 size?: 'fit' | 'S' | 'M';11};1213export const Button = ({14 className = '',15 variant,16 color,17 size = 'M',18 widthType = 'fill',19 textAlign = 'center',20 suffixSlot,21 prefixSlot,22 children,23 ...props24}: ButtonProps) => (25 <button26 className={`${styles.Button} ${className}`}27 {...props}28 data-variant={variant}29 data-color={color}30 data-size={size}31 data-width-type={widthType}32 data-text-align={textAlign}33 >34 {prefixSlot && <span className={styles.PrefixSlot}>{prefixSlot}</span>}35 {children && <span className={styles.Center}>{children}</span>}36 {suffixSlot && <span className={styles.SuffixSlot}>{suffixSlot}</span>}37 </button>38);
이 프로젝트에서 스타일은 CSS Modules를 사용해 처리하고 있다. 스타일 변형을 처리하기 위해, props로 받아온 값을 button 요소의 data 속성으로 넣어줬다. Button.module.css
파일에서는 아래와 같이 data 속성을 처리할 수 있다. 전체 코드는 여기에서 볼 수 있다.
이 구조에서는 조건이 복잡해질수록 CSS 구조도 복잡해진다는 문제가 있다. 구조가 복잡하면 특정 상황에서 어떤 스타일이 적용될지 예측하기 어려워진다. 여기에서 변형 타입이 추가되거나 특정 상황에서의 스타일이 변경되어야 하는 경우에도 수정할 부분을 찾기 어려울 것이다. 확장성과 유연성이 떨어지는 구조이다.
또, CSS 파일에서 사용하는 data 속성과 리액트 요소에 넘기는 data 속성값 간의 타입이 일치한다는 것이 보장되지 않기 때문에, 타입 안정성 문제도 있다. CSS를 작성하다가 잘못된 data 속성값을 사용해도 이를 미리 감지할 방법이 없다.
사실 이런 문제를 해결하기 위해 CSS-in-JS 라이브러리를 사용할 수 있지만, 그러려면 다시 작성해야 하는 코드가 너무 많기도 했고, 무엇보다도 CSS-in-JS를 별로 쓰고 싶지 않았다 ^^; (개취)
이를 보완하기 위해, CVA(Class Variance Authority) 라이브러리를 도입해 봤다.
CVA (Class Variance Authority)
CVA는 CSS에서 사용하는 클래스 스타일을 조건에 따라 변형해서 사용하는 것을 쉽게 관리할 수 있게 해 주는 라이브러리이다. UI 라이브러리인 shadcn/ui에서도 CVA를 활용해 스타일 변형을 관리하고 있다.
공식 문서에서는 주로 Tailwind CSS를 기준으로 설명하고 있는데, 이 글에서는 CSS Modules를 사용하는 경우에 초점을 맞췄다.
쓰면 어떤 게 좋아질까?
CVA를 적용한 후의 Button 컴포넌트를 먼저 살펴보자. CSS 코드는 여기에서 확인할 수 있다.
1type ButtonProps = DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {2 suffixSlot?: ReactNode;3 prefixSlot?: ReactNode;4} & VariantProps<typeof buttonStyle>;56export const Button = ({7 className = '',8 variant,9 color,10 size,11 widthType,12 textAlign,13 suffixSlot,14 prefixSlot,15 children,16 ...props17}: ButtonProps) => (18 <button className={`${buttonStyle({ variant, color, widthType, textAlign, size })} ${className}`} {...props}>19 {prefixSlot && <span className={styles.PrefixSlot}>{prefixSlot}</span>}20 {children && <span className={styles.Center}>{children}</span>}21 {suffixSlot && <span className={styles.SuffixSlot}>{suffixSlot}</span>}22 </button>23);
여기에서 달라진 점은 아래와 같다.
className
값으로styles.Button
이 아닌buttonStyle
함수 호출 결과를 넣어주고 있다. 이 함수를 호출할 때 인자로 스타일 변형을 위한 props를 넣어주고 있는데, 이 값들은 미리 설정해 둔 스타일 변형 config에 맞게 타입 체크가 수행되므로 타입에 안전하다.- data 속성이 제거됐다. CSS 모듈 안에서 data 속성에 따라 스타일을 주는 코드도 제거됐다. 어떤 variant가 오면 어떤 스타일을 지정해 줄지만 작성되어 있기 때문에, variant 조합에 따라 어떤 스타일이 적용될지 예측하기가 쉬워졌다.
설치
아래 커맨드로 CVA를 설치한다.
1npm i cva@npm:class-variance-authority
라이브러리의 패키지 명은 class-variance-authority
인데, cva라는 이름의 권한을 다른 사람이 가져갔었다가 2022년에 깃허브에서 이름을 돌려줬다고 한다. 나중에 v1 출시를 하면 cva
로 패키지 명을 바꿀 예정이라고 한다. (2024/11 현재는 v0.7)
위 커맨드로 alias 처리를 해주면, import path를 아래와 같이 짧게 사용할 수 있다.
1// 이렇게 하는 대신2import { cva } from 'class-variance-authority';34// alias 처리하여 이렇게 쓸 수 있다5import { cva } from 'cva';
사용하기
위에서 봤던 버튼 컴포넌트에서 cva를 사용하도록 리팩토링을 진행해 보려고 한다.
기본 스타일 적용하기
먼저, 버튼의 기본이 되는 CSS Class를 Button.module.css
파일에 생성한다.
1.Button {2 border: none;3 background-color: transparent;4 cursor: pointer;5 display: flex;6 gap: 8px;7 text-align: center;8 align-items: center;9}
Button.tsx
파일에서, 버튼의 classname을 생성할 buttonStyle
변수를 선언한다. 이때, 첫 번째 인자로 방금 만든 .Button
스타일을 넘겨준다.
1import { cva } from 'cva';2import styles from './Button.module.css';34const buttonStyle = cva(styles.Button, {});
buttonStyle
은 Button
컴포넌트의 button
엘리먼트에 classname으로 전달한다. 기존에는 스타일 변형을 위한 props들을 data 속성으로 넘겨줬는데, 앞으로 CSS Modules가 아닌 CVA가 스타일 변형을 관리하게 될 것이므로 data 속성도 모두 삭제해 줬다.
1export const Button = ({2 className = '',3 variant,4 color,5 size = 'M',6 widthType = 'fill',7 textAlign = 'center',8 suffixSlot,9 prefixSlot,10 children,11 ...props12}: ButtonProps) => (13 <button14 // 원래는 buttonStyle 대신 styles.Button을 받고 있었다15 className={`${buttonStyle} ${className}`}16 {...props}17 >18 {prefixSlot && <span className={styles.PrefixSlot}>{prefixSlot}</span>}19 {children && <span className={styles.Center}>{children}</span>}20 {suffixSlot && <span className={styles.SuffixSlot}>{suffixSlot}</span>}21 </button>22);
variant 추가하기
이번에는 기본 스타일에 더해서 변숫값에 따른 스타일을 지정해 주려고 한다. 우선 widthType, textAlign, size 변수에 대한 스타일을 작성했다.
1// Button.module.css2.WidthType_Fill {3 width: 100%;4}5.WidthType_Hug {6 width: fit-content;7}89.TextAlign_Left {10 text-align: left;11}12.TextAlign_Center {13 text-align: center;14}15.TextAlign_Right {16 text-align: right;17}1819.Size_M {20 height: 48px;21 border-radius: 48px;22 padding: 8px 16px;23 font-size: 16px;24 font-weight: 600;25}26.Size_S {27 height: 32px;28 border-radius: 40px;29 padding: 8px 12px;30}31.Size_Fit {32 padding-left: 0;33 padding-right: 0;34}35
그다음, cva
config의 variants에 각 변수에 대한 스타일을 지정해 줬다.
1// Button.tsx23const buttonStyle = cva(styles.Button, {4 // 각 변수에 대해 어떤 스타일을 머지할지 결정한다5 variants: {6 widthType: { fill: styles.WidthType_Fill, hug: styles.WidthType_Hug },7 textAlign: { left: styles.TextAlign_Left, center: styles.TextAlign_Center, right: styles.TextAlign_Right },8 size: { M: styles.Size_M, S: styles.Size_S, fit: styles.Size_Fit },9 },10});
사용할 때는 buttonStyle을 호출하면서, 원하는 variants 값을 객체 형태로 넘기면 된다. 예를 들어 아래와 같은 variants를 지정하면, 주석의 내용과 같이 클래스 스타일이 조합된다.
1buttonStyle({ widthType: 'fill', textAlign: 'left', size: 'fit' })2// _Button_167h4_1 _WidthType_Fill_167h4_72 _TextAlign_Left_167h4_80 _Size_Fit_167h4_66
복합 조건 설정하기
Button 컴포넌트의 props 중 variant와 color는 복합적으로 고려되어야 하는 조건이다. 예를 들어, variant=filled
라면, 버튼을 무슨 색상으로 채워야 할지는 color 값을 통해 정해진다.
이처럼 복합적으로 작용하는 조건은 config 중 compoundVariants
값으로 지정할 수 있다.
먼저, variant와 color의 각 조합에 맞게 스타일을 생성했다.
1// Button.module.css2.Variant_Filled_Primary {3 background-color: var(--color-neutral-900);4 color: var(--color-primary);5}67.Variant_Filled_Neutral {8 background-color: var(--color-neutral-200);9 color: #fff;10}1112.Variant_Outline_Primary {13 border: 1px solid var(--color-primary);14 color: var(--color-primary);15}1617.Variant_Outline_Neutral {18 border: 1px solid var(--color-neutral-300);19 color: var(--color-neutral-900);20}
buttonStyle
의 config에 compoundVariants를 추가했다. variant
와 color
의 경우 독립적으로는 스타일을 가지지 않기 때문에, variants에서는 빈 문자열을 등록해 뒀다.
1// Button.tsx2const buttonStyle = cva(styles.Button, {3 variants: {4 widthType: { fill: styles.WidthType_Fill, hug: styles.WidthType_Hug },5 textAlign: { left: styles.TextAlign_Left, center: styles.TextAlign_Center, right: styles.TextAlign_Right },6 size: { M: styles.Size_M, S: styles.Size_S, fit: styles.Size_Fit },7 variant: { filled: '', outline: '', ghost: '' },8 color: { primary: '', neutral: '' },9 },10 compoundVariants: [11 { variant: 'filled', color: 'primary', className: styles.Variant_Filled_Primary },12 { variant: 'filled', color: 'neutral', className: styles.Variant_Filled_Neutral },13 { variant: 'outline', color: 'primary', className: styles.Variant_Outline_Primary },14 { variant: 'outline', color: 'neutral', className: styles.Variant_Outline_Neutral },15 { variant: 'ghost', color: 'primary', className: styles.Variant_Ghost_Primary },16 { variant: 'ghost', color: 'neutral', className: styles.Variant_Ghost_Neutral },17 ],18});
TypeScript와 함께 사용하기
cva
에서 제공하는 VariantProps
타입을 사용하면, variants
값을 컴포넌트의 props로 받게 할 수 있다.
1// 컴포넌트의 Props 타입을 아래와 같이 정의할 수 있다2type ButtonProps = DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {3 suffixSlot?: ReactNode;4 prefixSlot?: ReactNode;5} & VariantProps<typeof buttonStyle>; // variants에 등록한 값을 props에 등록해 줄 수 있다
CVA에서 특정 variants를 required 값으로 지정하는 옵션은 제공하지 않기 때문에, 모든 값은 Optional 처리된다. 필요하다면 아래와 같이 required로 설정할 키만 Required
처리를 해주면 된다.
1type RequiredVariantKeys = 'widthType' | 'size';23type ButtonStyleProps = Omit<ButtonVariantProps, RequiredVariantKeys> &4 Required<Pick<ButtonVariantProps, RequiredVariantKeys>>;
config에 defaultVariants 값을 주면 각 variants의 기본값을 지정해 줄 수 있다.
1const buttonStyle = cva(styles.Button, {2 variants: {3 ...4 },5 compoundVariants: [6 ...7 ],8 defaultVariants: {9 variant: 'filled',10 color: 'primary',11 size: 'M',12 textAlign: 'center',13 },14});15
마치며
CSS Modules를 사용하면서 가장 아쉬운 점은 상황에 따라 다른 스타일을 보여줘야 하는 경우를 처리하기 어렵다는 점이었는데, CVA는 이런 부분을 잘 보완해 주는 라이브러리이다. 어떤 CVA 관련 논의 코멘트에서 ‘CVA는 발견하면 모든 컴포넌트를 새로 작성하고 싶어지는 라이브러리 중 하나'라는 내용이 있었는데, 공감이 많이 됐다.