테일윈드를 통한 컴포넌트 스타일링 방법 (Tailwind + CVA + tailwind-merge)

시소·2024년 5월 17일
0

Libraries

목록 보기
1/1
post-thumbnail

컴포넌트 스타일링 개요

웹 페이지에서 사용되는 컴포넌트들의 시각적인 모습과 레이아웃을 결정하기 위해 다양한 스타일링 기법들이 활용된다. 그중에서도 아래와 같은 몇 가지 방식이 대표적으로 사용된다:

  • Global CSS: 전통적인 방식으로 CSS를 작성하고, 컴포넌트에 클래스를 적용하는 방법. 주로 BEM 방법론을 따르는 경우가 많음.
  • CSS Modules: CSS 파일을 모듈화해 컴포넌트마다 지역 스코프를 갖도록 하는 방식. 클래스명 충돌 해결에 도움이 되며 스타일의 캡슐화 가능.
  • CSS Preprocessor: Variables, Nested rules, Mixins 등 다양한 기능으로 CSS를 확장하여 구조를 가독성 있고 유지보수 하기 좋도록 만듦. (Sass, Less 등)
  • CSS-in-JS(TS): 스타일을 JS(TS) 코드 내에 작성해 컴포넌트에 적용하는 방식. (styled-componentsEmotion 등)
  • Tailwind CSS: 클래스 기반의 CSS 프레임워크로, 사전 정의된 유틸리티 클래스들을 활용해 스타일을 적용하는 방식. 신속하게 사용자 정의 스타일 적용 가능.

오늘은 특히 테일윈드를 메인으로 하는 컴포넌트 스타일링 기법에 대해 알아보고, 코드를 개선해본 경험에 대해 공유해볼 것이다. 또한 같이 사용하면 유용한 라이브러리에 대해서도 함께 소개해보려 한다.

컴포넌트 스타일링 시작하기

tailwindcss

링크: https://tailwindcss.com/

아래 코드를 통해 테일윈드의 Core Concepts 중 하나인 유틸리티 우선 접근 방식에 대해 엿볼 수 있다.

테일윈드를 사용할 때는 별도의 CSS 코드를 작성하지 않아도 사전에 만들어져 있는(pre-existing) 다양한 유틸리티 클래스들을 활용해 요소의 스타일을 지정할 수 있다.

가장 바깥에 있는 <div> 예로 들면, 컨테이너로 만들기 위해 container 클래스를 지정하고, 화면에서 중앙에 배치하기 위해 mx-auto 클래스를, 또한 Flexbox 레이아웃을 지정하기 위해 관련 클래스들을 작성해준 모습이다.

기본적인 Layout, Spacing, Size, Typography, Effects, Transition 등에 대한 지원은 물론 Theme, Colors, Breakpoints 에 대한 클래스들을 모두 사용할 수 있으며 프로젝트마다 달라질 수 있는 세부 스타일 같은 경우 부분적으로 커스터마이징하는 설정 또한 가능하다.

이렇게 미리 만들어져 있는 클래스들을 여러 방면으로 활용해, 개발자 입장에서는 CSS를 작성하는데 필요한 시간을 크게 단축시킬 수 있고 일관된 디자인을 간편하게 유지할 수 있는 효과를 얻을 수 있다.

clsx

링크: https://github.com/lukeed/clsx

clsx는 클래스 명을 동적으로 생성하고 조건부로 적용하는 데 도움이 되는 라이브러리이다. 파일 크기가 매우 작고(239B), 의존성이 없어 경량이라는 특징을 가지고 있다.

import clsx from 'clsx'

const buttonClasses = clsx(
  'base-class',
  true && 'conditional-class', {
    'another-class': true,
    'ignored-class': false,
  }
) // 'base-class conditional-class another-class'

...

<button className={buttonClasses}>Click me</button>

이와 같이 단순한 방법으로 여러 종류의 문자열, 객체, 배열 등을 결합하여 클래스를 생성할 수 있다.

twMerge

링크: https://github.com/dcastil/tailwind-merge

tailwind-merge는 그 이름 그대로, Tailwind CSS의 클래스를 병합하는 데 사용되는 라이브러리이다.

단순히 클래스를 합치는 데 그치지 않고, 만일 충돌하는 클래스가 있는 경우 마지막으로 설정된 클래스가 우위를 가진다. (Ex. twMerge("p-5 p-2 p-4") → "p-4")

import { twMerge } from 'tailwind-merge'

const baseClass = 'text-center'
const extraClasses = 'text-left text-xl text-blue-500'

const mergedClasses = twMerge(baseClass, extraClasses)
// 'text-left text-xl text-blue-500'

...

<button className={mergedClasses}>Click me</button>

clsx와 종종 같이 사용되어 Tailwind 및 기타 CSS 클래스를 효과적으로 조합하고 관리하는 것이 가능하며, 이를 통해 코드를 좀 더 유연하고 관리하기 쉽게 만들 수 있다.

이를 활용해 만든 Button 컴포넌트

지난 번 포스팅한 (1) Storybook 으로 구축하는 Design System – 디자인 시스템 개요와 컴포넌트 빌드 편에서, 버튼 공통 컴포넌트를 하나 만들었었다.

기존 코드

아래는 해당 컴포넌트의 코드 중 일부이다.

import clsx from "clsx";
import { twMerge } from "tailwind-merge";

...

interface ButtonProps {
  variant?: ButtonVariant; // 버튼의 생김새
  pill?: boolean; // 둥근 모양인지 여부
  size?: ButtonSize; // 버튼의 크기
  disabled?: boolean; // 버튼 비활성화 여부
  label?: string; // 버튼에 표시할 내용
  loading?: boolean; // 로딩 중 여부
  onClick?: (e?: React.MouseEvent<HTMLDivElement>) => void; // 클릭 시 호출할 함수
}

export const Button = ({
  variant = "primary",
  pill = false,
  size = "md",
  disabled = false,
  label = "",
  loading = false,
  onClick,
  ...props
}: ButtonProps) => {
  const buttonClasses = twMerge(
    clsx("rounded-md justify-center items-center flex cursor-pointer", {
      "bg-slate-900 hover:bg-slate-700 active:bg-slate-600": variant === "primary",
      "bg-red-500 hover:bg-red-600 active:bg-red-700": variant === "danger",
      "border border-slate-200 bg-white hover:bg-slate-50 active:bg-slate-100":
        variant === "outlined",
      "bg-slate-100 hover:bg-slate-200 active:bg-slate-300": variant === "subtle",
      "bg-white/opacity-0 hover:bg-slate-100 active:bg-slate-200": variant === "ghost",
      "bg-white/opacity-0 hover:underline": variant === "link",

      "min-w-16 h-8 p-2 gap-1": size === "sm",
      "min-w-24 h-10 p-2.5 gap-2": size === "md",
      "min-w-32 h-12 p-3 gap-2.5": size === "lg",

      "rounded-full": pill,

      "cursor-wait": loading,
      "opacity-50 hover:bg- active:bg- cursor-not-allowed": disabled,
    }),
  );
  const buttonTextClasses = clsx(`font-medium font-['Inter'] pointer-events-none`, {
    "text-white": variant === "primary" || variant === "danger",
    "text-slate-900": variant === "outlined" || variant === "subtle" || variant === "ghost",

    "text-xs": size === "sm",
    "text-sm": size === "md",
    "text-lg": size === "lg",
  });
  
  ...
  
  return (
    <div className={buttonClasses}>
      <button className={buttonTextClasses}>
        {label}
      </button>
    </div>
  )
}

다양한 props를 기반으로 clsxtwMerge의 기능을 활용해 동적으로 클래스를 만들어내고 있다.

문제점

위의 방식은 간단하게 다양한 조합의 클래스로 버튼을 만들어 낼 수 있지만, 코드가 매우 복잡해 보인다. 그에 따라 가독성과 유지보수성 또한 떨어질 우려가 있다. 따라서 이를 개선시키기 위해 좀 더 효율적으로 조건부 클래스를 관리할 수 있는 방안이 필요하다.

Button 컴포넌트 개선

CVA (Class Variance Authority)

링크: https://cva.style/docs

위에서 작성한 Button 컴포넌트의 코드를 개선하기 위해 찾은 라이브러리는 바로 CVA 라는 라이브러리로, 조건부 클래스를 좀 더 구조적이고 재사용 가능하게 만들어주는 도구이다.

cva 함수를 사용해 클래스명을 선언적 방식으로 구성하여 스타일을 관리할 수 있다. 다음은 예제 코드이다.

import { cva, type VariantProps } from "class-variance-authority";

// CVA를 사용해 클래스 변형 정의
const makeButton = cva('base-button', {
  variants: {
    variant: {
      primary: 'bg-blue-500 text-white',
      secondary: 'bg-gray-500 text-white',
    },
    size: {
      sm: 'p-2 text-sm',
      md: 'p-4 text-md',
      lg: 'p-6 text-lg',
    },
  },
  defaultVariants: {
    variant: 'primary',
    size: 'md',
  },
})

// 버튼 컴포넌트 타입 정의
interface ButtonProps extends VariantProps<typeof makeButton> {
  label: string;
}

// 버튼 컴포넌트 구현
const NewButton = ({ variant, size, label }: ButtonProps) => {
  return <button className={makeButton({ variant, size })}>{label}</button>
}

// 사용 예시
const App = () => {
  <>
    <NewButton label="Primary button" variant="primary" size="md" />
    <NewButton label="Secondary button" variant="secondary" size="sm" />
  </>
}

위의 예시에서 cva 함수로 makeButton을 정의했다. 해당 함수는 기본 클래스인 'base-button'과 함께 다양한 props에 대응하기 위해 변형을 정의하는 객체를 받고 있다.

변형은 variantsize 2가지로 나뉘며, 각각 여러 옵션을 가질 수 있다.
또한 defaultVariants 에서 기본 변형을 설정할 수도 있다.

이제 NewButton 컴포넌트의 className에 makeButton 함수를 사용해 생성한 클래스를 지정해 버튼에 다양한 스타일을 적용시킬 수 있다.

이런 활용법을 통해 기존에 작성했던 맘에 들지 않는 코드를 수정해보자.

수정한 코드

  • 기존의 clsx를 떼어내고 cva 함수로 대체하였다. 이는 스타일 변경을 좀 더 깔끔하고 체계적으로 관리할 수 있게 해준다.
  • VariantProps를 통해 타입스크립트 환경에서 CVA 스타일 변형에 대해 타입 안전성을 제공한다.
import { twMerge } from "tailwind-merge";
import { cva, type VariantProps } from "class-variance-authority";

const buttonStyles = cva("rounded-md justify-center items-center flex cursor-pointer", {
  variants: {
    variant: {
      primary: "bg-slate-900 hover:bg-slate-700 active:bg-slate-600",
      danger: "bg-red-500 hover:bg-red-600 active:bg-red-700",
      outlined: "border border-slate-200 bg-white hover:bg-slate-50 active:bg-slate-100",
      subtle: "bg-slate-100 hover:bg-slate-200 active:bg-slate-300",
      ghost: "bg-white/opacity-0 hover:bg-slate-100 active:bg-slate-200",
      link: "bg-white/opacity-0 hover:underline",
    },
    size: {
      sm: "min-w-16 h-8 p-2 gap-1",
      md: "min-w-24 h-10 p-2.5 gap-2",
      lg: "min-w-32 h-12 p-3 gap-2.5",
    },
    pill: {
      true: "rounded-full",
    },
    loading: {
      true: "cursor-wait",
    },
    disabled: {
      true: "opacity-50 hover:bg- active:bg- cursor-not-allowed",
    },
  },
  defaultVariants: {
    variant: "primary",
    size: "md",
  },
});

const buttonTextStyles = cva(`font-medium font-['Inter'] pointer-events-none`, {
  variants: {
    variant: {
      primary: "text-white",
      danger: "text-white",
      outlined: "text-slate-900",
      subtle: "text-slate-900",
      ghost: "text-slate-900",
      link: "text-slate-900",
    },
    size: {
      sm: "text-xs",
      md: "text-sm",
      lg: "text-lg",
    },
  },
});

type ButtonVariant = "primary" | "danger" | "outlined" | "subtle" | "ghost" | "link";
type ButtonSize = "sm" | "md" | "lg";

interface ButtonProps extends VariantProps<typeof buttonStyles> {
  variant?: ButtonVariant; // 버튼의 생김새
  pill?: boolean; // 둥근 모양인지 여부
  size?: ButtonSize; // 버튼의 크기
  disabled?: boolean; // 버튼 비활성화 여부
  label?: string; // 버튼에 표시할 내용
  loading?: boolean; // 로딩 중 여부
  onClick?: (e?: React.MouseEvent<HTMLDivElement>) => void; // 클릭 시 호출할 함수
}

export const Button = ({
  variant = "primary",
  pill = false,
  size = "md",
  disabled = false,
  label = "",
  loading = false,
  onClick,
  ...props
}: ButtonProps) => {
  return (
    <div
      className={twMerge(buttonStyles({ variant, size, pill, loading, disabled }))}
      onClick={!loading && !disabled ? onClick : undefined}
    >
      <button
        className={twMerge(buttonTextStyles({ variant, size }))}
        disabled={disabled}
        {...props}
      >
        {label}
      </button>
    </div>
  );
};

CVA 채택 이유

  • 버튼 컴포넌트에는 매우 다양한, 여러 가지 변형이 올 수 있다. (variant, size, pill, loading, disabled 등) 그리고 또 추후에 이런 요인이 더 추가될 수도 있다.
  • CVA는 추가 기능으로 합성 변형(Compound Variants)을 지원하기에, 미리 특정 변형을 정의해 두어 스타일링 로직을 재사용 하는데도 도움이 된다.
  • 또한 clsx와 다르게 TypeScript와 결합해 변형에 대해서도 타입 안전성을 제공할 수 있으므로, 컴포넌트를 사용할 때의 실수를 줄일 수 있을 거라 생각했다.
  • 이렇듯 다양한 변형을 다루는 버튼 컴포넌트와 같은 경우에 CVA가 좀 더 유리할 것이라 생각하여 기존의 clsx를 CVA로 변경하였다.
  • clsx는 좀 더 단순한 조건부 로직에 적합한 도구인 것 같다.
profile
배우고 익힌 것을 나만의 언어로 정리하는 공간 ..🛝

0개의 댓글