웹 페이지에서 사용되는 컴포넌트들의 시각적인 모습과 레이아웃을 결정하기 위해 다양한 스타일링 기법들이 활용된다. 그중에서도 아래와 같은 몇 가지 방식이 대표적으로 사용된다:
오늘은 특히 테일윈드를 메인으로 하는 컴포넌트 스타일링 기법에 대해 알아보고, 코드를 개선해본 경험에 대해 공유해볼 것이다. 또한 같이 사용하면 유용한 라이브러리에 대해서도 함께 소개해보려 한다.
tailwindcss
아래 코드를 통해 테일윈드의 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를 기반으로 clsx
와 twMerge
의 기능을 활용해 동적으로 클래스를 만들어내고 있다.
위의 방식은 간단하게 다양한 조합의 클래스로 버튼을 만들어 낼 수 있지만, 코드가 매우 복잡해 보인다. 그에 따라 가독성과 유지보수성 또한 떨어질 우려가 있다. 따라서 이를 개선시키기 위해 좀 더 효율적으로 조건부 클래스를 관리할 수 있는 방안이 필요하다.
Button
컴포넌트 개선CVA
(Class Variance Authority)위에서 작성한 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에 대응하기 위해 변형을 정의하는 객체를 받고 있다.
변형은 variant
와 size
2가지로 나뉘며, 각각 여러 옵션을 가질 수 있다.
또한 defaultVariants
에서 기본 변형을 설정할 수도 있다.
이제 NewButton 컴포넌트의 className에 makeButton
함수를 사용해 생성한 클래스를 지정해 버튼에 다양한 스타일을 적용시킬 수 있다.
이런 활용법을 통해 기존에 작성했던 맘에 들지 않는 코드를 수정해보자.
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>
);
};