← 글 목록

notes

tailwind merge , clsx , cva, Radix UI 를 활용한 재사용 가능한 ui 캡슐화

tailwind-merge·clsx·cva·Radix UI로 재사용 가능한 UI를 캡슐화하는 구성 메모.

  • cn = twMerge(clsx(...)) 하나로 조건부 클래스와 충돌 정리를 동시에 처리한다.
  • 변형은 cva 로 모으고, VariantProps 로 타입까지 뽑는다.
  • React 19 에서는 ref 가 일반 prop 이라 forwardRef 없이도 된다.

왜 이 조합을 찾았나

사내 프로젝트에서 디자인 시스템 기준으로 퍼블리싱하고 컴포넌트를 나누는 작업을 하고 있었다.

UI 를 아토믹 단위로 효율적으로 빼 둔 방법이 없을까 고민하다가 cva, tailwind-merge, clsx 세 가지를 알게 됐다. 이 글에서는 이 세 개로 재사용 가능한 UI 컴포넌트를 정리해 본다.

구성의 특징은 세 가지다.

  • ref 전달: 부모가 자식의 DOM 노드를 직접 다뤄야 할 때를 위해 ref 를 넘긴다.
  • 클래스 문자열 기반 스타일: CSS-in-JS 런타임 없이, 스타일을 Tailwind 클래스 문자열로 JS 파일 안에서 정의한다.
  • 유틸리티 함수: 클래스 병합은 cn 하나로 처리한다.

1. 유틸리티 함수 cn

Tailwind 클래스를 합치다 보면 px-2 와 px-4 처럼 충돌이 생긴다. 그래서 cn 을 먼저 정의해 둔다.

// utils/class-names.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
  • clsx: 조건부 클래스를 문자열로 합친다. { "bg-red-500": isError } 처럼 쓴다.
  • twMerge: 같은 속성을 건드리는 클래스가 여럿이면 뒤의 것만 남긴다. px-2 px-4 는 px-4 가 된다.

순서가 중요하다. 밖에서 넘긴 className 을 마지막에 둔어야 기본 스타일을 덮어쓸 수 있다.


2. cva 로 variant 관리

컴포넌트의 변형(variant)을 한 곳에서 정의한다. 기본 클래스 + variant 별 클래스 + 기본값 구조다.

buttonVariants
// utils/variants.ts
import { cva, type VariantProps } from "class-variance-authority";

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md font-medium transition-colors",
  {
    variants: {
      variant: {
        primary: "bg-blue-500 text-white hover:bg-blue-600",
        outline: "border border-gray-300 bg-transparent hover:bg-gray-50",
        ghost: "hover:bg-gray-100",
      },
      size: {
        sm: "h-8 px-3 text-sm",
        md: "h-10 px-4 py-2",
        lg: "h-12 px-6 text-lg",
      },
    },
    defaultVariants: {
      variant: "primary",
      size: "md",
    },
  }
);

export type ButtonProps = VariantProps<typeof buttonVariants>;
export { buttonVariants };
  • variant 와 size 를 조합해서 여러 버튼을 만들 수 있어 스타일이 일관된다.
  • VariantProps 로 타입이 자동으로 나온다. variant="primry" 같은 오타는 타입 에러가 난다.

3. 재사용 가능한 컴포넌트

아토믹 디자인의 Atoms 단계인 Button 예시다. 처음에는 React.forwardRef 로 짰다.

Button (forwardRef 버전)
// components/ui/button.tsx
import React from "react";
import { buttonVariants, type ButtonProps } from "@/utils/variants";
import { cn } from "@/utils/class-names";

interface ButtonExtendedProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    ButtonProps {}

const Button = React.forwardRef<HTMLButtonElement, ButtonExtendedProps>(
  ({ className, variant, size, ...props }, ref) => (
    <button
      ref={ref}
      className={cn(buttonVariants({ variant, size }), className)}
      {...props}
    />
  )
);

Button.displayName = "Button";
export default Button;

React 19 부터는 함수 컴포넌트가 ref 를 일반 prop 으로 받는다. 그래서 지금은 이렇게 짧게 쓸 수 있다. forwardRef 는 아직 동작하지만 앞으로 빠질 예정이다.

type ButtonExtendedProps = React.ComponentProps<"button"> & ButtonProps;

export function Button({ className, variant, size, ...props }: ButtonExtendedProps) {
  return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}

React.ComponentProps<"button"> 안에 ref 까지 들어 있어서 ...props 로 그대로 넘어간다. onClick, disabled 같은 기존 버튼 속성도 다 호환된다.

<Button variant="outline" size="lg" onClick={() => alert("Clicked!")}>
  Click Me
</Button>

4. Radix 의 Slot 으로 asChild

제목에 Radix UI 를 넣은 이유가 이 부분이다. 버튼 스타일을 <a> 나 라우터의 Link 에 입히고 싶을 때, Slot 을 쓰면 태그를 바꾸지 않고 클래스와 props 만 자식에게 넘길 수 있다.

asChild 버전 Button
import { Slot } from "radix-ui";

type Props = React.ComponentProps<"button"> & ButtonProps & { asChild?: boolean };

export function Button({ className, variant, size, asChild, ...props }: Props) {
  const Comp = asChild ? Slot.Root : "button";
  return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}

// 사용
<Button asChild variant="ghost">
  <a href="/docs">문서</a>
</Button>

예전에는 @radix-ui/react-slot 처럼 컴포넌트별 패키지를 깔았는데, 지금은 radix-ui 한 패키지에서 다 가져올 수 있다. Dialog, Popover 같은 복잡한 컴포넌트도 접근성과 키보드 동작은 Radix 에 맡기고, 스타일만 cva 로 입히는 식으로 같은 패턴을 쓴다.


5. 더 다듬기

Compound Components

Card, Accordion 처럼 여러 조각으로 된 컴포넌트는 Compound Pattern 으로 나눈다.

const Card = ({ className, ...props }: React.ComponentProps<"div">) => (
  <div className={cn("rounded-lg border bg-card", className)} {...props} />
);

const CardHeader = ({ className, ...props }: React.ComponentProps<"div">) => (
  <div className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />
);

<Card>
  <CardHeader>Title</CardHeader>
</Card>;

테마 토큰

Tailwind v3 까지는 tailwind.config.js 의 theme.extend 에서 디자인 토큰을 정의했다. v4 부터는 CSS 파일에서 @theme 으로 정의하는 게 기본이다.

v3 config 와 v4 @theme
// v3: tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: "var(--color-primary)",
      },
    },
  },
};
/* v4: globals.css */
@import "tailwindcss";

@theme {
  --color-primary: oklch(0.62 0.19 255);
}
/* bg-primary, text-primary 가 바로 생긴다 */

v4 로 올리면 tailwind-merge 도 3.x 로 같이 올려야 한다. 2.x 는 v3 클래스 기준이라 v4 에서 새로 생긴 클래스나 바뀐 이름을 충돌로 인식하지 못한다.


6. 정리

  • 일관성: cva 로 variant 를 한 곳에 모아서 디자인 시스템을 맞춘다.
  • 유지보수: 스타일 로직이 유틸 파일에 모여 있어서 고치기 쉽다.
  • 확장성: 새 컴포넌트도 같은 패턴을 그대로 쓴다.
  • 깔끔한 출력: tailwind-merge 가 중복, 충돌 클래스를 정리해서 DOM 에 남는 클래스가 예측 가능하다.