재사용을 염두에 둔 컴포넌트를 구현하다보면, 하나의 컴포넌트에 style variant를 정의해야하는 경우가 생긴다.
prop으로 어떤 스타일이 적용될지 전달받는 것인데, 전달해주지 않아도 기본값으로 일정 이상의 스타일이 적용되어 있어야 한다.
이전에는 컴포넌트 파일에 size, backgroundColor, textSize 등을 각 각 지정해두고 props으로 넘겨받을 수 있게 구성했었다. 그리고 사용하는 곳에서 스타일을 추가했을 때 그 스타일도 적용되어야 했다.
어느 스타일까지가 공통적으로 적용되고 어디서부터 variant로 나뉘는 것인지 한 눈에 보기도 어렵고, 스타일 속성이 여러 개의 prop으로 나눠져서 받아야해서 사용하는 사람이 한 눈에 이해하기 어려웠다.
이번에는 style variant를 한 눈에 이해하기 쉽고, 사용하기도 편한 컴포넌트를 만들어보려고 했다.
프로젝트 환경
- Next.js 14.2.17
- TailwindCSS 3.4.15
외부에서의 스타일 확장을 위한 Tailwind-merge의 twMerge
https://www.npmjs.com/package/tailwind-merge
tailwind-merge는 tailwindCSS의 클래스를 병합할 때 사용하는 함수로, 클래스가 중복되거나 충돌할 때 발생하는 문제를 해결하는 기능을 수행한다.
쉽게 말하면 미리 정의되어 있는 스타일과, 추가할 스타일을 합쳐주는 것인데,
import { twMerge } from 'tailwind-merge'
twMerge('px-2 py-1 bg-red hover:bg-dark-red', 'p-3 bg-[#B91C1C]')
// → 'hover:bg-dark-red p-3 bg-[#B91C1C]'
이 예시를 보면 알 수 있듯, 새로 제공한 (두 번째 인자로 제공된 스타일 코드가 더 우선시 됨) 스타일과 이전 스타일이 있을 때 불필요하게 중복되는 스타일 코드 (적용되지 않을 스타일 코드)를 제거해주고 병합해주는 것이다.
* 위의 코드에서는 bg-red와 px-2, py-1이 병합 과정에 의해서 제거되었다.
이러한 제거를 통해 클래스 간 충돌이나 덮어쓰기 문제를 자동으로 해결해주어 예상치 못한 UI 문제를 줄일 수 있다.
그래서 우선 나는 정의한 컴포넌트에서 twMerge를 이용해서 컴포넌트를 사용할 때 스타일을 주입해줄 수 있도록 할 것이다. 스타일을 바꿀 수도 있겠지만, 주로 레이아웃에서 원하는 곳에 배치하기 위해서 이러한 확장 스타일을 주입해주게 될 것 같다.
Style Variant를 정의하고 사용하기 위한 class-variance-authority의 cva, cx
class-variance-authority는 앞서 style-variant를 가독성 좋게 정의하고 사용하고 싶다는 목적을 달성하기 위해 사용할 도구이다.
cx는 앞서 이야기한 스타일의 조건부 렌더링을 보다 간편하게 할 수 있게 도와주고, cva는 style variant들을 직관적으로 정의하고 사용할 수 있게 도와준다.
cx대신 clsx를 사용하는 경우도 있지만, 나는 tailwindCSS를 기반으로 사용하기 때문에 이에 보다 최적화 된 cva의 cx를 사용하려고 한다. cva를 통해서 variant를 관리하기도 하기 때문에 최대한 같은 패키지 내에서 동일한 기능을 찾아서 사용하려 했다.
이제 어떤 것들을 왜 사용해야하는 지 알아봤으니, 사용법에 대해서 자세히 알아보자
사용해보자
우선 사용하기 위해서 설치를 해줘야겠죠? 공식 문서에서 tailwindCSS를 사용하는 경우를 위한 가이드를 따로 작성해둬서 이를 따라서 차근차근 하기만 하면 된다. 그래서 나는 따로 정리하지 않을 예정 !
https://cva.style/docs/getting-started/installation
공식 문서를 보면, styled-conflicts를 방지할 수 있도록 설계되었다고는 하지만, 완벽하지 않다고 이야기하고 있다. 그리고 그러한 충돌 방지를 위해 twMerge를 함께 사용하는 예시를 함께 제공하고 있어서 나도 twMerge도 함께 설치해주었다.
> npm i tailwind-merge
이렇게 설치가 끝났다면, 이제 실제로 적용을 해보자 !
나는 Button 컴포넌트에 variant를 설정해줄 것이다.
저번에 봤던 View를 활용한 Button 컴포넌트에 스타일을 적용해주려고 한다 !
사용법은 생각보다 쉽다.
cva('기본적으로 적용할 스타일', {
variants : {
variant : {
스타일 분기 : '',
스타일 분기 : '',
},
size : {
lg : '',
md : '',
sm : '',
}
},
// 기본 variant 설정 근데 이건 기본적으로 적용할 스타일에 따라서 없어도 되긴 한다.
defaultVariants : {
variant : '',
size : 'md',
}
},
);
이 사용법대로, variant를 한 번 작성해서 만들어보자.
export const buttonVariant = cva(
[
'relative rounded-full border border-solid px-6 py-3 text-white',
'after:absolute after:inset-0 after:-z-10 after:min-h-full after:min-w-full after:translate-x-1.5 after:translate-y-1.5 after:rounded-full after:border after:border-solid after:transition-transform after:content-[""]',
'transition duration-150 ease-in-out hover:after:-translate-x-0 hover:after:-translate-y-0',
],
{
variants: {
variant: {
white: 'border-gray-130 bg-white text-gray-130 after:border-gray-130 after:bg-white active:bg-gray-30',
black: 'bg-gray-130 after:bg-gray-130 active:bg-gray-150',
primary: 'bg-green-20 after:bg-green-20 active:bg-green-40',
secondary: 'bg-green-40 after:bg-green-40 active:bg-green-60',
},
size: {
lg: 'px-8 py-5 text-2xl font-bold',
md: 'px-7 py-4 text-base',
sm: 'px-6 py-3 text-sm',
},
},
defaultVariants: {
variant: 'white',
size: 'md',
},
},
);
// 기본 스타일을 배열로 넣어줄 수도 있다.
// 배열로 넣어준 이유는, 적용되는 스타일별로 나눈 것이다.
// 버튼 형태, 버튼 뒤에 깔리는 형태, 애니메이션
이렇게 스타일 variant를 작성했다면, VariantProps를 이용해서 prop으로 불러올 수 있도록 해줘야 한다.
import { cx } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';
export function cn(...classes: (string | undefined)[]) {
return twMerge(cx(classes));
}
스타일 병합을 도와주는 twMerge와, 조건부 스타일 적용을 도와줄 cx를 한번에 사용할 수 있는 유틸함수를 만들어두는 것이다
이건 실제 Button 컴포넌트에 적용되도록 해보자 !
import { cva, type VariantProps } from 'class-variance-authority';
import { ElementType, forwardRef } from 'react';
import View, { PolymorphicProps, PolymorphicRef } from '@components/View';
import { cn } from '@utils/cn';
export const buttonVariant = cva(
[
'relative rounded-full border border-solid px-6 py-3 text-white',
'after:absolute after:inset-0 after:-z-10 after:min-h-full after:min-w-full after:translate-x-1.5 after:translate-y-1.5 after:rounded-full after:border after:border-solid after:transition-transform after:content-[""]',
'transition duration-150 ease-in-out hover:after:-translate-x-0 hover:after:-translate-y-0',
],
{
variants: {
variant: {
white: 'border-gray-130 bg-white text-gray-130 after:border-gray-130 after:bg-white active:bg-gray-30',
black: 'bg-gray-130 after:bg-gray-130 active:bg-gray-150',
primary: 'bg-green-20 after:bg-green-20 active:bg-green-40',
secondary: 'bg-green-40 after:bg-green-40 active:bg-green-60',
},
size: {
lg: 'px-8 py-5 text-2xl font-bold',
md: 'px-7 py-4 text-base',
sm: 'px-6 py-3 text-sm',
},
},
defaultVariants: {
variant: 'white',
size: 'md',
},
},
);
type ButtonVariant = VariantProps<typeof buttonVariant>;
type ButtonProps<T extends ElementType> = PolymorphicProps<T> & ButtonVariant;
const Button = forwardRef(
<T extends ElementType = 'button'>(
{ as, variant, size, className, ...props }: ButtonProps<T>,
ref: PolymorphicRef<T>,
) => {
return (
<View as={as || 'button'} className={cn(buttonVariant({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = 'Button';
export default Button;
이렇게 버튼 컴포넌트를 정의했고, 스토리북을 이용해서 각 각의 variant가 적용된 버튼을 볼 수 있게 했다.
// Button.stories.tsx
import { Meta, StoryObj } from '@storybook/react';
import Button from '.';
const meta = {
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
args: {
children: '버튼입니다',
},
argTypes: {
variant: {
control: 'select', // select로 드롭다운 제공
options: ['white', 'black', 'primary', 'secondary'], // buttonVariant에서 정의한 variants를 기반으로 옵션 제공
description: '버튼의 스타일 변형을 설정합니다.',
},
size: {
control: 'select',
options: ['lg', 'md', 'sm'],
description: '버튼의 크기를 설정합니다.',
},
className: {
control: false,
},
as: {
control: false,
},
},
} satisfies Meta<typeof Button>;
export default meta;
type ButtonStoryObj = StoryObj<typeof Button>;
export const Primary: ButtonStoryObj = {
args: {
variant: 'primary',
},
};
export const Secondary: ButtonStoryObj = {
args: {
variant: 'secondary',
},
};
export const Large: ButtonStoryObj = {
args: {
size: 'lg',
},
};
export const Small: ButtonStoryObj = {
args: {
size: 'sm',
},
};
export const White: ButtonStoryObj = {
args: {
variant: 'white',
},
};
export const Black: ButtonStoryObj = {
args: {
variant: 'black',
},
};
적용 후기
이렇게 cva 패키지의 cva, cx, twMerge를 활용해서 tailwind의 styleVariant를 가독성 있게 작성해봤다.
이 방식은 Next.js의 서버 컴포넌트 환경에서도 아주 잘 동작한다.
그럼 오늘은 이만~!
안뇽 !
'🗂️ 개발 이모저모' 카테고리의 다른 글
shadcn으로 Dropdown을 만들어보자 (0) | 2025.01.03 |
---|---|
node_modules, 패키지 크기가 작아지면 좋은 점이 뭘까? (2) | 2024.12.02 |
스토리 북에 대해서 알아보자 ! (3) | 2024.11.04 |
카카오톡 공유하기를 내 맘대로 바꿔보자 (with.기본 템플릿 커스텀) (6) | 2024.10.03 |
카카오톡 공유하기를 원하는 대로 만들어보자 ! (with. 메시지 템플릿) (5) | 2024.09.29 |