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 별 클래스 + 기본값 구조다.
// 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 로 짰다.
// 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 만 자식에게 넘길 수 있다.
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: 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 에 남는 클래스가 예측 가능하다.