Next.js에서 class-variance-authority(CVA)로 재사용 가능한 UI 컴포넌트 만들기

2025. 4. 14. 10:10·TypeScript
반응형
# 설치
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와 함께 사용할 때 특히 강력한 조합을 이루며, 다음과 같은 이점을 제공합니다:

  1. 타입 안전성: TypeScript와 완벽하게 통합되어 컴포넌트 변형의 타입 안전성을 보장합니다.
  2. 일관성: 중앙 집중식 디자인 토큰과 변형 관리로 일관된 UI를 유지할 수 있습니다.
  3. 재사용성: 컴포넌트를 다양한 상황에 맞게 변형하여 사용할 수 있습니다.
  4. 유지보수성: 디자인 변경이 필요할 때 한 곳에서 수정하면 모든 인스턴스에 적용됩니다.
  5. 명확한 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
'TypeScript' 카테고리의 다른 글
  • Next.js의 Image 컴포넌트 가이드
  • 브레드크럼(Breadcrumb): 사용자 경험을 향상시키는 네비게이션 요소
  • Next.js Script 컴포넌트 : 최적화된 스크립트 로딩
  • 웹사이트 SEO를 위한 메타데이터 최적화 완벽 가이드: 검색 노출부터 소셜 공유까지
코샵
코샵
나의 코딩 일기장
    반응형
  • 코샵
    끄적끄적 코딩 공방
    코샵
    • 분류 전체보기 (730)
      • 스마트팜 (1)
      • 상품 추천 (223)
      • DataBase (0)
        • MongoDB (4)
        • PostgreSQL (0)
      • 하드웨어 (19)
      • 일기장 (4)
      • 파이썬 (131)
        • Basic (42)
        • OpenCV (8)
        • Pandas (15)
        • PyQT (3)
        • SBC(Single Board Computer) (1)
        • 크롤링 (14)
        • Fast API (29)
        • Package (6)
      • Unity (138)
        • Tip (41)
        • Project (1)
        • Design Pattern (8)
        • Firebase (6)
        • Asset (2)
      • Linux (5)
      • C# (97)
        • Algorithm (11)
        • Window (7)
      • TypeScript (51)
        • CSS (10)
      • Git (11)
      • SQL (5)
      • Flutter (10)
        • Tip (1)
      • System (1)
      • BaekJoon (6)
      • Portfolio (2)
      • MacOS (1)
      • 유틸리티 (1)
      • 서비스 (6)
      • 자동화 (3)
      • Hobby (10)
        • 물생활 (10)
        • 식집사 (0)
  • 인기 글

  • 태그

    programming101
    C#
    리스트
    라떼우유
    믈레코비타멸균우유
    셀레니움
    Python
    상품 리뷰 크롤링
    codingcommunity
    스크립트 실행
    unity
    유니티
    programmerlife
    appdevelopment
    리뷰이관
    파이썬
    리뷰관리
    list
    스마트스토어리뷰
    learntocode
    ipcamera
    긴유통기한우유
    쇼핑몰리뷰
    카페24리뷰
    rtsp
    스크립트 실행 순서
    codingtips
    카페24리뷰이관
    cv2
    devlife
  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
코샵
Next.js에서 class-variance-authority(CVA)로 재사용 가능한 UI 컴포넌트 만들기
상단으로

티스토리툴바