# 설치
npm install class-variance-authority
# 또는
yarn add class-variance-authority
Next.js로 개발할 때 일관된 디자인 시스템을 유지하면서 유연한 컴포넌트를 만드는 것은 매우 중요합니다. 이번 글에서는 class-variance-authority(CVA) 라이브러리를 활용해 타입 안전하고 유지보수가 쉬운 재사용 가능한 UI 컴포넌트를 구축하는 방법에 대해 자세히 알아보겠습니다.
CVA란 무엇인가?
class-variance-authority는 Tailwind CSS와 같은 유틸리티 기반 CSS 프레임워크와 함께 사용하여 컴포넌트 변형(variants)을 관리하는 라이브러리입니다. 이 라이브러리는 타입스크립트 지원을 통해 컴포넌트의 모든 가능한 변형을 타입 안전하게 정의할 수 있게 해줍니다.
기본 사용법
1. 간단한 버튼 컴포넌트 만들기
// components/ui/Button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { ButtonHTMLAttributes, forwardRef } from 'react';
// 버튼 스타일 변형 정의
const buttonVariants = cva(
// 기본 클래스
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none",
{
variants: {
// 크기 변형
size: {
default: "h-10 py-2 px-4",
sm: "h-9 px-3",
lg: "h-11 px-8",
icon: "h-10 w-10",
},
// 색상 변형
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "underline-offset-4 hover:underline text-primary",
},
},
// 기본값 설정
defaultVariants: {
size: "default",
variant: "default",
},
}
);
// 버튼 컴포넌트 프롭스 타입 정의
interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
// 버튼 컴포넌트
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
className,
variant,
size,
asChild = false,
...props
}, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
});
Button.displayName = "Button";
export { Button, buttonVariants };
2. 유틸리티 함수 설정
Tailwind CSS와 함께 CVA를 사용할 때 클래스 이름을 병합하기 위한 유틸리티 함수가 필요합니다.
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
// 클래스명 병합 유틸리티
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
3. 컴포넌트 사용 예시
// app/page.tsx
import { Button } from '@/components/ui/Button';
export default function Home() {
return (
<div className="space-y-4 p-8">
<h1 className="text-2xl font-bold">CVA 버튼 예시</h1>
{/* 기본 버튼 */}
<Button>기본 버튼</Button>
{/* 크기 변형 */}
<div className="flex gap-4 items-center">
<Button size="sm">작은 버튼</Button>
<Button size="default">기본 크기 버튼</Button>
<Button size="lg">큰 버튼</Button>
</div>
{/* 색상 변형 */}
<div className="flex gap-4 flex-wrap">
<Button variant="default">기본</Button>
<Button variant="destructive">삭제</Button>
<Button variant="outline">외곽선</Button>
<Button variant="secondary">보조</Button>
<Button variant="ghost">고스트</Button>
<Button variant="link">링크</Button>
</div>
{/* 크기와 색상 조합 */}
<Button size="lg" variant="destructive">
큰 삭제 버튼
</Button>
{/* 추가 클래스 적용 */}
<Button className="animate-pulse">애니메이션 버튼</Button>
</div>
);
}
복합 컴포넌트 패턴 구현하기
CVA는 복합 컴포넌트 패턴(Compound Component Pattern)과 함께 사용하면 더욱 강력해집니다. 카드 컴포넌트를 예로 들어보겠습니다.
// components/ui/Card.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { HTMLAttributes, forwardRef } from 'react';
// 카드 컨테이너 스타일
const cardVariants = cva(
"rounded-lg border bg-card text-card-foreground shadow-sm",
{
variants: {
padding: {
default: "p-6",
compact: "p-4",
spacious: "p-8",
},
bordered: {
true: "border-2",
false: "border",
},
},
defaultVariants: {
padding: "default",
bordered: false,
},
}
);
// 카드 헤더 스타일
const cardHeaderVariants = cva(
"flex flex-col space-y-1.5",
{
variants: {
padding: {
default: "pb-4",
compact: "pb-2",
spacious: "pb-6",
},
},
defaultVariants: {
padding: "default",
},
}
);
// 카드 타이틀 스타일
const cardTitleVariants = cva(
"text-lg font-semibold leading-none tracking-tight",
{
variants: {
size: {
default: "text-lg",
large: "text-xl",
small: "text-base",
},
},
defaultVariants: {
size: "default",
},
}
);
// 카드 설명 스타일
const cardDescriptionVariants = cva(
"text-sm text-muted-foreground",
{
variants: {
italic: {
true: "italic",
false: "",
},
},
defaultVariants: {
italic: false,
},
}
);
// 카드 컨텐츠 스타일
const cardContentVariants = cva(
"text-base",
{
variants: {},
defaultVariants: {},
}
);
// 카드 푸터 스타일
const cardFooterVariants = cva(
"flex items-center",
{
variants: {
align: {
start: "justify-start",
center: "justify-center",
end: "justify-end",
between: "justify-between",
},
padding: {
default: "pt-4",
compact: "pt-2",
spacious: "pt-6",
},
},
defaultVariants: {
align: "start",
padding: "default",
},
}
);
// 카드 컴포넌트 프롭스 타입
interface CardProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardVariants> {}
// 카드 컴포넌트
const Card = forwardRef<HTMLDivElement, CardProps>(({
className,
padding,
bordered,
...props
}, ref) => (
<div
ref={ref}
className={cn(cardVariants({ padding, bordered, className }))}
{...props}
/>
));
Card.displayName = "Card";
// 카드 헤더 컴포넌트
interface CardHeaderProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardHeaderVariants> {}
const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(({
className,
padding,
...props
}, ref) => (
<div
ref={ref}
className={cn(cardHeaderVariants({ padding, className }))}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
// 카드 타이틀 컴포넌트
interface CardTitleProps
extends HTMLAttributes<HTMLHeadingElement>,
VariantProps<typeof cardTitleVariants> {}
const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(({
className,
size,
...props
}, ref) => (
<h3
ref={ref}
className={cn(cardTitleVariants({ size, className }))}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
// 카드 설명 컴포넌트
interface CardDescriptionProps
extends HTMLAttributes<HTMLParagraphElement>,
VariantProps<typeof cardDescriptionVariants> {}
const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionProps>(({
className,
italic,
...props
}, ref) => (
<p
ref={ref}
className={cn(cardDescriptionVariants({ italic, className }))}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
// 카드 컨텐츠 컴포넌트
interface CardContentProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardContentVariants> {}
const CardContent = forwardRef<HTMLDivElement, CardContentProps>(({
className,
...props
}, ref) => (
<div
ref={ref}
className={cn(cardContentVariants({ className }))}
{...props}
/>
));
CardContent.displayName = "CardContent";
// 카드 푸터 컴포넌트
interface CardFooterProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardFooterVariants> {}
const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(({
className,
align,
padding,
...props
}, ref) => (
<div
ref={ref}
className={cn(cardFooterVariants({ align, padding, className }))}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
};
복합 컴포넌트 사용 예시
// app/card-example.tsx
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter
} from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
export default function CardExample() {
return (
<div className="p-8 space-y-8">
<h1 className="text-2xl font-bold">카드 컴포넌트 예시</h1>
{/* 기본 카드 */}
<Card>
<CardHeader>
<CardTitle>기본 카드</CardTitle>
<CardDescription>이것은 기본 스타일의 카드입니다.</CardDescription>
</CardHeader>
<CardContent>
<p>여기에 카드 내용이 들어갑니다.</p>
</CardContent>
<CardFooter>
<Button>확인</Button>
</CardFooter>
</Card>
{/* 변형 적용 카드 */}
<Card padding="spacious" bordered={true}>
<CardHeader padding="spacious">
<CardTitle size="large">고급 카드</CardTitle>
<CardDescription italic={true}>스타일이 변경된 카드입니다.</CardDescription>
</CardHeader>
<CardContent>
<p>여기에 카드 내용이 들어갑니다.</p>
</CardContent>
<CardFooter align="between" padding="spacious">
<Button variant="outline">취소</Button>
<Button>확인</Button>
</CardFooter>
</Card>
{/* 커스텀 스타일이 추가된 카드 */}
<Card className="bg-gradient-to-r from-purple-500 to-blue-500 text-white">
<CardHeader>
<CardTitle className="text-white">그라데이션 카드</CardTitle>
<CardDescription className="text-white opacity-80">커스텀 배경이 적용된 카드입니다.</CardDescription>
</CardHeader>
<CardContent>
<p>CVA와 Tailwind를 함께 사용하면 매우 유연합니다.</p>
</CardContent>
<CardFooter align="center">
<Button className="bg-white text-blue-500 hover:bg-white/90">확인</Button>
</CardFooter>
</Card>
</div>
);
}
조건부 변형 처리하기
CVA는 변형을 조합하는 강력한 방법을 제공합니다. 다음과 같이 Badge 컴포넌트를 만들어 볼 수 있습니다.
// components/ui/Badge.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { HTMLAttributes, forwardRef } from 'react';
const badgeVariants = cva(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground border border-input",
success: "bg-green-500 text-white hover:bg-green-600",
warning: "bg-yellow-500 text-white hover:bg-yellow-600",
info: "bg-blue-500 text-white hover:bg-blue-600",
},
// 조건부 변형 - 가시성
visibility: {
visible: "opacity-100",
hidden: "opacity-0 hidden",
},
// 조건부 변형 - 크기
size: {
default: "text-xs px-2.5 py-0.5",
sm: "text-xs px-2 py-0.5 rounded-md",
lg: "text-sm px-3 py-1",
},
},
// 특정 변형 조합에 대한 클래스 오버라이드
compoundVariants: [
{
variant: "success",
size: "lg",
className: "font-bold tracking-wider",
},
{
variant: "outline",
size: "sm",
className: "border-dashed",
},
],
defaultVariants: {
variant: "default",
visibility: "visible",
size: "default",
},
}
);
interface BadgeProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
const Badge = forwardRef<HTMLDivElement, BadgeProps>(({
className,
variant,
visibility,
size,
...props
}, ref) => (
<div
ref={ref}
className={cn(badgeVariants({ variant, visibility, size, className }))}
{...props}
/>
));
Badge.displayName = "Badge";
export { Badge, badgeVariants };
사용 예시
// app/badge-example.tsx
import { Badge } from '@/components/ui/Badge';
export default function BadgeExample() {
return (
<div className="p-8 space-y-6">
<h1 className="text-2xl font-bold">배지 컴포넌트 예시</h1>
<div className="flex gap-2 flex-wrap">
<Badge>기본</Badge>
<Badge variant="secondary">보조</Badge>
<Badge variant="destructive">삭제</Badge>
<Badge variant="outline">외곽선</Badge>
<Badge variant="success">성공</Badge>
<Badge variant="warning">경고</Badge>
<Badge variant="info">정보</Badge>
</div>
<div className="flex gap-2 items-center">
<Badge size="sm">작게</Badge>
<Badge size="default">기본 크기</Badge>
<Badge size="lg">크게</Badge>
</div>
{/* 복합 변형 예시 */}
<div className="flex gap-2">
<Badge variant="success" size="lg">성공 (큰 크기)</Badge>
<Badge variant="outline" size="sm">외곽선 (작은 크기)</Badge>
</div>
{/* 조건부 표시 */}
<div>
<Badge visibility="hidden">이 배지는 보이지 않습니다</Badge>
</div>
</div>
);
}
양식 컴포넌트 만들기
CVA로 양식 요소도 쉽게 만들 수 있습니다. 예를 들어 Input 컴포넌트를 만들어 보겠습니다.
// components/ui/Input.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { InputHTMLAttributes, forwardRef } from 'react';
const inputVariants = cva(
"flex rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
size: {
default: "h-10",
sm: "h-8 px-2 py-1 text-xs",
lg: "h-12 px-4 py-3 text-base",
},
variant: {
default: "border-gray-300",
error: "border-red-500 focus-visible:ring-red-500",
success: "border-green-500 focus-visible:ring-green-500",
},
width: {
default: "w-full",
auto: "w-auto",
fixed: "w-64",
},
},
defaultVariants: {
size: "default",
variant: "default",
width: "default",
},
}
);
export interface InputProps
extends InputHTMLAttributes<HTMLInputElement>,
VariantProps<typeof inputVariants> {}
const Input = forwardRef<HTMLInputElement, InputProps>(({
className,
size,
variant,
width,
type = "text",
...props
}, ref) => {
return (
<input
type={type}
className={cn(inputVariants({ size, variant, width, className }))}
ref={ref}
{...props}
/>
);
});
Input.displayName = "Input";
export { Input, inputVariants };
폼 컴포넌트 조합하기
라벨과 인풋을 함께 사용하는 FormField 컴포넌트를 만들어 보겠습니다.
// components/ui/FormField.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { HTMLAttributes, ReactNode, forwardRef } from 'react';
import { Input, InputProps } from './Input';
const formFieldVariants = cva(
"space-y-2",
{
variants: {
inline: {
true: "flex items-center space-y-0 space-x-2",
false: "space-y-2",
},
},
defaultVariants: {
inline: false,
},
}
);
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
{
variants: {
variant: {
default: "text-foreground",
error: "text-red-500",
success: "text-green-500",
},
required: {
true: "after:content-['*'] after:ml-0.5 after:text-red-500",
false: "",
},
},
defaultVariants: {
variant: "default",
required: false,
},
}
);
const helperTextVariants = cva(
"text-sm",
{
variants: {
variant: {
default: "text-muted-foreground",
error: "text-red-500",
success: "text-green-500",
},
},
defaultVariants: {
variant: "default",
},
}
);
interface FormFieldProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof formFieldVariants> {
label?: string;
helperText?: string;
inputProps?: InputProps;
variantState?: "default" | "error" | "success";
required?: boolean;
}
const FormField = forwardRef<HTMLDivElement, FormFieldProps>(({
className,
inline,
label,
helperText,
inputProps,
variantState = "default",
required = false,
...props
}, ref) => {
return (
<div
ref={ref}
className={cn(formFieldVariants({ inline, className }))}
{...props}
>
{label && (
<label
htmlFor={inputProps?.id}
className={cn(labelVariants({ variant: variantState, required }))}
>
{label}
</label>
)}
<Input
variant={variantState}
{...inputProps}
aria-required={required}
/>
{helperText && (
<p className={cn(helperTextVariants({ variant: variantState }))}>
{helperText}
</p>
)}
</div>
);
});
FormField.displayName = "FormField";
export { FormField };
폼 사용 예시
// app/form-example.tsx
import { FormField } from '@/components/ui/FormField';
import { Button } from '@/components/ui/Button';
import { useState } from 'react';
export default function FormExample() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [emailError, setEmailError] = useState('');
const validateEmail = (value: string) => {
if (!value) {
setEmailError('이메일은 필수입니다.');
return false;
}
if (!/\S+@\S+\.\S+/.test(value)) {
setEmailError('유효한 이메일 주소를 입력하세요.');
return false;
}
setEmailError('');
return true;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const isEmailValid = validateEmail(email);
if (isEmailValid) {
alert('폼 제출 성공!');
}
};
return (
<div className="p-8 max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-6">로그인 폼</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<FormField
label="이메일"
required
variantState={emailError ? "error" : "default"}
helperText={emailError || "회사 이메일을 입력하세요."}
inputProps={{
type: "email",
placeholder: "example@company.com",
value: email,
onChange: (e) => setEmail(e.target.value),
onBlur: () => validateEmail(email),
}}
/>
<FormField
label="비밀번호"
required
inputProps={{
type: "password",
placeholder: "비밀번호를 입력하세요",
value: password,
onChange: (e) => setPassword(e.target.value),
}}
/>
<div className="pt-4">
<Button type="submit" size="lg" className="w-full">
로그인
</Button>
</div>
</form>
</div>
);
}
컨텍스트 기반 컴포넌트 사용 예시
// app/alert-example.tsx
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/Alert';
export default function AlertExample() {
return (
<div className="p-8 space-y-6 max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-4">알림 컴포넌트 예시</h1>
{/* 기본 알림 */}
<Alert>
<AlertTitle>기본 알림</AlertTitle>
<AlertDescription>
이것은 기본 알림 메시지입니다. 중요한 정보를 제공합니다.
</AlertDescription>
</Alert>
{/* 오류 알림 */}
<Alert variant="destructive">
<AlertTitle>오류 발생!</AlertTitle>
<AlertDescription>
작업을 처리하는 중에 오류가 발생했습니다. 다시 시도해주세요.
</AlertDescription>
</Alert>
{/* 성공 알림 */}
<Alert variant="success">
<AlertTitle>성공!</AlertTitle>
<AlertDescription>
변경사항이 성공적으로 저장되었습니다.
</AlertDescription>
</Alert>
{/* 경고 알림 */}
<Alert variant="warning">
<AlertTitle>주의 필요</AlertTitle>
<AlertDescription>
계정 활동에 의심스러운 행동이 감지되었습니다.
</AlertDescription>
</Alert>
{/* 정보 알림 */}
<Alert variant="info">
<AlertTitle>알림</AlertTitle>
<AlertDescription>
시스템 업데이트가 예정되어 있습니다. 자세한 내용은 공지사항을 확인하세요.
</AlertDescription>
</Alert>
</div>
);
}
테마 전환 지원
다크 모드와 같은 테마 전환을 지원하는 CVA 컴포넌트를 만들어 보겠습니다. 예를 들어 간단한 Card 컴포넌트를 만들어 봅시다.
// components/ui/ThemeCard.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { HTMLAttributes, forwardRef } from 'react';
const themeCardVariants = cva(
"rounded-lg p-6 transition-colors",
{
variants: {
variant: {
default: "",
elevated: "shadow-md",
bordered: "border",
},
// 같은 컴포넌트에서 테마별 스타일 정의
theme: {
// 라이트 모드
light: "bg-white border-gray-200 text-gray-900",
// 다크 모드
dark: "bg-gray-800 border-gray-700 text-gray-100",
// 테마 시스템 활용 (Tailwind 다크 모드)
system: "bg-background border-border text-foreground dark:bg-gray-800 dark:border-gray-700 dark:text-gray-100",
},
},
// 조합별 특별 스타일
compoundVariants: [
{
variant: "elevated",
theme: "light",
className: "shadow-gray-200",
},
{
variant: "elevated",
theme: "dark",
className: "shadow-gray-900",
},
],
defaultVariants: {
variant: "default",
theme: "system",
},
}
);
interface ThemeCardProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof themeCardVariants> {}
const ThemeCard = forwardRef<HTMLDivElement, ThemeCardProps>(({
className,
variant,
theme,
...props
}, ref) => (
<div
ref={ref}
className={cn(themeCardVariants({ variant, theme, className }))}
{...props}
/>
));
ThemeCard.displayName = "ThemeCard";
export { ThemeCard, themeCardVariants };
테마 전환 예시
// app/theme-example.tsx
"use client";
import { ThemeCard } from '@/components/ui/ThemeCard';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
export default function ThemeExample() {
const [theme, setTheme] = useState<"light" | "dark" | "system">("system");
return (
<div className="p-8 space-y-8">
<h1 className="text-2xl font-bold">테마 전환 예시</h1>
<div className="flex gap-4">
<Button
variant={theme === "light" ? "default" : "outline"}
onClick={() => setTheme("light")}
>
라이트 모드
</Button>
<Button
variant={theme === "dark" ? "default" : "outline"}
onClick={() => setTheme("dark")}
>
다크 모드
</Button>
<Button
variant={theme === "system" ? "default" : "outline"}
onClick={() => setTheme("system")}
>
시스템 설정
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<ThemeCard
theme={theme}
variant="default"
>
<h3 className="text-lg font-semibold mb-2">기본 카드</h3>
<p>테마에 따라 스타일이 변경됩니다.</p>
</ThemeCard>
<ThemeCard
theme={theme}
variant="elevated"
>
<h3 className="text-lg font-semibold mb-2">그림자 카드</h3>
<p>그림자 효과가 테마에 맞게 적용됩니다.</p>
</ThemeCard>
<ThemeCard
theme={theme}
variant="bordered"
>
<h3 className="text-lg font-semibold mb-2">테두리 카드</h3>
<p>테두리 색상이 테마에 맞게 변경됩니다.</p>
</ThemeCard>
</div>
</div>
);
}
모바일 최적화 컴포넌트
반응형 디자인을 고려한 컴포넌트를 만들어 보겠습니다.
// components/ui/ResponsiveContainer.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { HTMLAttributes, forwardRef } from 'react';
const responsiveContainerVariants = cva(
"w-full mx-auto transition-all",
{
variants: {
padding: {
none: "",
sm: "px-4",
md: "px-6",
lg: "px-8",
},
maxWidth: {
none: "",
sm: "max-w-screen-sm",
md: "max-w-screen-md",
lg: "max-w-screen-lg",
xl: "max-w-screen-xl",
full: "max-w-full",
},
responsive: {
true: "md:px-6 lg:px-8", // 반응형 패딩
false: "",
},
},
compoundVariants: [
{
padding: "none",
responsive: true,
className: "px-4 md:px-6 lg:px-8",
},
],
defaultVariants: {
padding: "md",
maxWidth: "lg",
responsive: true,
},
}
);
interface ResponsiveContainerProps
extends HTMLAttributes<HTMLDivElement>,
VariantProps<typeof responsiveContainerVariants> {}
const ResponsiveContainer = forwardRef<HTMLDivElement, ResponsiveContainerProps>(({
className,
padding,
maxWidth,
responsive,
...props
}, ref) => (
<div
ref={ref}
className={cn(responsiveContainerVariants({ padding, maxWidth, responsive, className }))}
{...props}
/>
));
ResponsiveContainer.displayName = "ResponsiveContainer";
export { ResponsiveContainer, responsiveContainerVariants };
결론
class-variance-authority는 Tailwind CSS와 함께 사용할 때 특히 강력한 조합을 이루며, 다음과 같은 이점을 제공합니다:
- 타입 안전성: TypeScript와 완벽하게 통합되어 컴포넌트 변형의 타입 안전성을 보장합니다.
- 일관성: 중앙 집중식 디자인 토큰과 변형 관리로 일관된 UI를 유지할 수 있습니다.
- 재사용성: 컴포넌트를 다양한 상황에 맞게 변형하여 사용할 수 있습니다.
- 유지보수성: 디자인 변경이 필요할 때 한 곳에서 수정하면 모든 인스턴스에 적용됩니다.
- 명확한 API: 개발자들이 사용할 수 있는 모든 옵션이 명확하게 정의되어 있습니다.
이 글에서 살펴본 패턴과 컴포넌트를 활용하면 Next.js 프로젝트에서 강력하고 유지보수하기 쉬운 디자인 시스템을 구축할 수 있습니다. Tailwind CSS의 유틸리티 클래스와 CVA의 변형 관리 기능을 결합하여 빠르게 개발하면서도 확장 가능한 UI 컴포넌트를 만들 수 있습니다.
모든 컴포넌트에서 cn 유틸리티를 활용하여 Tailwind 클래스를 병합할 수 있으며, cva 함수로 모든 가능한 변형을 명확하게 정의할 수 있습니다. 특히 복합 컴포넌트 패턴과 결합했을 때 매우 유연한 UI 시스템을 구축할 수 있습니다.
CVA를 활용하여 디자인 시스템을 구축하면 개발 속도를 높이고, 코드 중복을 줄이며, 더 나은 개발자 경험을 제공할 수 있습니다.
'TypeScript' 카테고리의 다른 글
Next.js의 Image 컴포넌트 가이드 (0) | 2025.04.29 |
---|---|
브레드크럼(Breadcrumb): 사용자 경험을 향상시키는 네비게이션 요소 (1) | 2025.04.15 |
Next.js Script 컴포넌트 : 최적화된 스크립트 로딩 (1) | 2025.04.13 |
웹사이트 SEO를 위한 메타데이터 최적화 완벽 가이드: 검색 노출부터 소셜 공유까지 (1) | 2025.04.11 |
React Context API: 상태 관리 (0) | 2025.04.10 |