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를 위한 메타데이터 최적화 완벽 가이드: 검색 노출부터 소셜 공유까지
코샵
코샵
나의 코딩 일기장
    반응형
  • 코샵
    끄적끄적 코딩 공방
    코샵
  • 전체
    오늘
    어제
    • 분류 전체보기 (514) N
      • 상품 추천 (33)
      • MongoDB (4)
      • 하드웨어 (2) N
      • 일기장 (4)
      • Unity (138)
        • Tip (41)
        • Project (1)
        • Design Pattern (8)
        • Firebase (6)
        • Asset (2)
      • 파이썬 (127)
        • Basic (40)
        • OpenCV (8)
        • Pandas (15)
        • PyQT (3)
        • SBC(Single Board Computer) (1)
        • 크롤링 (14)
        • Fast API (29)
        • Package (6)
      • Linux (4)
      • C# (97)
        • Algorithm (11)
        • Window (7)
      • TypeScript (48)
        • 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)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 다비즈
  • 공지사항

  • 인기 글

  • 태그

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

  • 최근 글

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

티스토리툴바