스타일 · Shadcn UI · cva · cn · 테마 · 폼

Shadcn UI · cva, cn, 테마, 폼

Shadcn UI? 컴포넌트를 떠받치는 네 가지 핵심 패턴을 봅니다 — 클래스 합치기(cn), 모양 변형 만들기(cva), 색을 한곳에서 바꾸는 테마(CSS? 변수), 그리고 react-hook-form? + zod 폼입니다. 마지막으로 asChild 패턴까지 정리합니다.

이 패턴들은 "옷가게의 작업 도구"와 같아요. cn은 옷을 겹쳐 입힐 때 위에 입은 게 보이게 정리하는 손길, cva는 같은 디자인을 색·사이즈별로 찍어내는 틀, 테마는 매장 전체 색을 한 번에 바꾸는 조명 스위치, 폼은 손님이 잘못 적으면 바로 알려주는 점원입니다.

1. cn — 클래스 똑똑하게 합치기

cn 은 여러 클래스 문자열을 하나로 합치는 작은 도우미입니다. 두 라이브러리를 겹쳐 씁니다.

두 단계로 동작
  • clsx조건부 클래스를 정리. isActive && "bg-blue-500" 처럼 거짓이면 빼고, 참이면 넣어 문자열로 이어 붙입니다.
  • tailwind-merge — 합쳐진 문자열에서 충돌하는 Tailwind 클래스를 정리. 같은 속성이 겹치면 뒤에 온 것이 이깁니다(예: px-2 px-4px-4).

이 프로젝트의 실제 cn 입니다(src/lib/utils.ts) — 딱 두 줄입니다.

import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

왜 둘 다 필요할까요? clsx만 쓰면 px-2px-4 가 둘 다 남아 어느 게 적용될지 애매합니다. tailwind-merge가 뒤를 살려 깔끔하게 정리합니다.

// isActive 가 true 라고 하면
cn("px-2", isActive && "bg-blue-500", "px-4")

// 1) clsx 결과:          "px-2 bg-blue-500 px-4"
// 2) tailwind-merge 결과: "bg-blue-500 px-4"   ← px-2 는 px-4 에 밀림
왜 "뒤가 이김"이 중요한가

컴포넌트는 기본 클래스를 갖고, 쓰는 쪽에서 className 으로 덮어쓰고 싶을 때가 많습니다. cn(기본, props.className) 처럼 props를 뒤에 두면, 넘긴 클래스가 기본을 자연스럽게 이깁니다. 이게 Shadcn 컴포넌트가 자유롭게 커스터마이즈되는 비결입니다.

2. cva — 한 컴포넌트의 여러 모양

버튼 하나에도 "기본/위험/외곽선/고스트…" 여러 모양이 필요합니다. 매번 클래스를 손으로 적는 대신, cva(class-variance-authority 0.7)로 변형(variant) 표를 만들어 둡니다. variant·size 같은 키를 주면 알맞은 클래스를 돌려줍니다.

아래는 공식 문서의 Button 예입니다 — 모양(variant)과 크기(size)를 정의하고 기본값을 둡니다.

import { cva, type VariantProps } from "class-variance-authority"

const buttonVariants = cva(
  // 모든 변형이 공유하는 기본 클래스
  "inline-flex items-center justify-center rounded-md text-sm font-medium",
  {
    variants: {
      variant: {
        default:     "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-white hover:bg-destructive/90",
        outline:     "border border-input bg-background hover:bg-accent",
        secondary:   "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost:       "hover:bg-accent hover:text-accent-foreground",
        link:        "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4 py-2",
        sm:      "h-8 px-3 text-xs",
        lg:      "h-10 px-8",
        icon:    "size-9",
      },
    },
    defaultVariants: {   // 아무것도 안 주면 이 값으로
      variant: "default",
      size: "default",
    },
  }
)

여기서 타입은 한 줄로 끝납니다. VariantProps 가 위 표에서 가능한 값들을 자동으로 뽑아 타입으로 만들어 줍니다.

type ButtonProps = VariantProps<typeof buttonVariants>
// → { variant?: "default" | "destructive" | "outline" | ... ; size?: ... }

buttonVariants({ variant: "outline", size: "sm" })
// → "inline-flex ... border border-input bg-background ... h-8 px-3 text-xs"

실제 컴포넌트에서는 cva 결과를 다시 cn 으로 감싸 className props까지 합칩니다.

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

// 쓰는 쪽
<Button variant="outline" size="lg">취소</Button>
이 프로젝트의 실제 Button

study-frontend/src/components/ui/button.tsx 도 같은 cva 패턴이지만, 디자인이 손질되어 variant에 default/outline/secondary/ghost/destructive/link, size에 default/sm/lg/xs/icon/icon-sm/icon-lg… 까지 늘어나 있고, 타입 시그니처가 ButtonPrimitive.Props & VariantProps<typeof buttonVariants> 로 되어 있습니다. "복사형"이라 자유롭게 늘린 것입니다.

3. 테마 — 색을 한곳에서 바꾸기

Shadcn은 색을 컴포넌트마다 박지 않고 CSS 변수로 한곳에 모읍니다. 색 이름은 --background(바탕), --foreground(글자), --primary(주색), --muted(흐린 배경), --border(테두리), --ring(포커스 링) 처럼 역할로 짓습니다. 값은 oklch 색 공간을 씁니다(밝기·채도·색상을 사람 눈에 자연스럽게 다루는 방식).

이 프로젝트는 기준색(baseColor)이 neutral 이고 CSS 변수 방식(cssVariables: true)입니다(components.json). 실제 src/app/global.css:root 토큰 일부입니다.

:root {
  --background: oklch(1 0 0);            /* 흰 바탕 */
  --foreground: oklch(0.145 0 0);        /* 거의 검정 글자 */
  --primary: oklch(0.59 0.14 242);       /* 파란 주색 */
  --primary-foreground: oklch(0.98 0.01 237);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --border: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
  --radius: 0.5rem;
}

그런데 이 변수들을 Tailwind 에서 bg-background text-foreground 처럼 쓰려면 다리를 놓아야 합니다. Tailwind v4에서는 @theme inline 으로 변수를 유틸리티 클래스로 매핑합니다 — --color-background: var(--background) 한 줄이 bg-background 클래스를 만들어 줍니다.

@theme inline {
  --color-background: var(--background);  /* → bg-background, text-background 생성 */
  --color-foreground: var(--foreground);
  --color-primary: var(--primary);
  --color-muted: var(--muted);
  --color-border: var(--border);
  /* ... */
}

/* 그래서 마크업에서는 이렇게 */
<body class="bg-background text-foreground"> ... </body>
CSS 변수는 "물감 팔레트", @theme inline 은 "팔레트에 이름표 붙이기"입니다. 이름표가 붙으면 화가(HTML? 클래스)는 색 코드를 외울 필요 없이 "background 색 줘"라고만 하면 됩니다.

4. 다크 모드 — .dark 클래스 토글

비결은 간단합니다. 같은 변수 이름에 다른 값을 주는 .dark 블록을 하나 더 두고, 최상위 요소(보통 <html>)에 dark 클래스를 붙였다 뗐다 하면 됩니다. 그러면 페이지 전체 색이 한 번에 바뀝니다.

/* 실제 global.css */
.dark {
  --background: oklch(0.145 0 0);   /* 어두운 바탕 */
  --foreground: oklch(0.985 0 0);   /* 밝은 글자 */
  --muted: oklch(0.269 0 0);
  --border: oklch(0.269 0 0);
  /* ... 같은 이름, 다른 값 */
}

토글은 next-themes 같은 라이브러리를 쓰거나, 직접 클래스만 켜고 꺼도 됩니다. 공식 Vite 가이드는 가벼운 직접 방식을 보여줍니다.

// 가장 단순한 직접 토글
const root = document.documentElement
root.classList.toggle("dark")   // 켜면 .dark 변수들이 적용됨

한편 컴포넌트가 다크일 때만 특정 스타일을 더 주고 싶으면 Tailwind?dark: variant 를 씁니다. .dark 가 켜져 있을 때만 적용됩니다.

<div class="bg-white text-black dark:bg-zinc-900 dark:text-white">
  라이트에선 흰 배경, 다크에선 어두운 배경
</div>

5. 폼 — react-hook-form + zod + shadcn Form

Shadcn 폼은 세 가지를 조합합니다. react-hook-form(입력 상태 관리), zod(검증 규칙을 한곳에 선언), 그리고 shadcn Form 컴포넌트(라벨·에러 메시지 등 화면 뼈대). 검증 규칙을 화면이 아니라 스키마에 모으는 게 핵심입니다.

흐름
  • ① zod 스키마 — "이름은 2글자 이상, 이메일은 형식 맞게" 같은 규칙을 선언
  • ② useForm + zodResolver — 폼을 만들고 검증을 스키마에 위임
  • ③ FormField — 각 입력을 render props로 그리고, field 를 받아 입력에 연결
  • ④ FormItem / FormLabel / FormControl / FormMessage — 한 묶음(라벨·입력·에러)을 일관되게 배치. 에러는 FormMessage 가 자동 표시
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import {
  Form, FormField, FormItem, FormLabel, FormControl, FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"

// ① 규칙은 스키마에 모은다
const schema = z.object({
  username: z.string().min(2, "2글자 이상 적어주세요"),
})

function ProfileForm() {
  // ② 폼 생성 + 검증을 스키마에 위임
  const form = useForm({
    resolver: zodResolver(schema),
    defaultValues: { username: "" },
  })

  function onSubmit(values) {
    console.log(values)   // 검증을 통과한 값만 들어온다
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        {/* ③ 입력 하나를 render props로 */}
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>사용자 이름</FormLabel>
              <FormControl>
                <Input placeholder="hong" {...field} />
              </FormControl>
              <FormMessage />   {/* 에러 메시지 자동 표시 */}
            </FormItem>
          )}
        />
        <Button type="submit">저장</Button>
      </form>
    </Form>
  )
}
왜 이렇게 나누나

검증 규칙(스키마)·상태 관리(react-hook-form)·화면(Form 컴포넌트)이 깔끔히 분리됩니다. 규칙을 바꿀 땐 스키마만, 모양을 바꿀 땐 컴포넌트만 손대면 됩니다. FormMessage 는 해당 필드의 에러를 알아서 찾아 보여줘 직접 if 문을 쓸 필요가 없어요.

6. asChild + Radix Slot — 내 props를 자식에게 넘기기

가끔 "버튼처럼 생겼지만 사실은 링크"가 필요합니다. 이때 <Button> 안에 또 <a> 를 넣으면 태그가 두 겹이 됩니다. asChild 를 쓰면 버튼이 자기 태그를 만들지 않고, 자신의 스타일·동작(props)을 바로 자식에게 넘깁니다.

import { Button } from "@/components/ui/button"

// asChild 없으면: <button><a>...</a></button>  (이중 태그)
// asChild 있으면: <a class="버튼 스타일">...</a>   (a 하나에 합쳐짐)
<Button asChild>
  <a href="/home">홈으로</a>
</Button>

이걸 가능하게 하는 부품이 Radix Slot(@radix-ui/react-slot)입니다. <Slot> 은 "내가 받은 props를 그대로 내 자식에게 합쳐 전달하라"는 특수 컴포넌트예요. asChild 가 켜지면 컴포넌트는 평소의 <button> 대신 <Slot> 을 렌더링합니다.

import { Slot } from "@radix-ui/react-slot"

function Button({ asChild, className, ...props }) {
  // asChild 면 Slot, 아니면 진짜 button 을 쓴다
  const Comp = asChild ? Slot : "button"
  return <Comp className={cn(buttonVariants(), className)} {...props} />
}
<Slot> 은 "옷을 대신 입혀주는 마네킹"이 아니라 "옷만 벗어서 옆 사람에게 입히는 손"입니다. 자기 몸(태그)은 사라지고, 들고 있던 옷(className·onClick 등)을 자식에게 그대로 넘겨 입힙니다.
이 프로젝트는 Base UI — render prop

asChild + <Slot> 은 Radix UI(=shadcn 전통 기본) 방식입니다. 이 프로젝트가 쓰는 Base UI(@base-ui/react)는 같은 일을 render prop 으로 합니다 — 예: <Menu.Item render={<a href="/x" />}>이동</Menu.Item> 처럼 "이 컴포넌트를 어떤 태그로 렌더할지"를 직접 넘겨요. 개념(이중 태그 없이 자식에 동작·props 위임)은 동일합니다.

한눈에 — 패턴 요약

패턴하는 일핵심
cn클래스 문자열 합치기clsx(조건부) + tailwind-merge(뒤가 이김)
cva한 컴포넌트의 여러 모양variants·size·defaultVariants + VariantProps
테마색을 한곳에서 관리CSS 변수(oklch) + @theme inline 매핑, baseColor neutral
다크 모드전체 색 전환.dark 클래스 토글 + dark: variant
입력 + 검증 + 화면useForm + zodResolver + FormField/Item/Control/Message
asChildprops를 자식에게 위임Radix <Slot> (이중 태그 방지)
이 프로젝트와의 관계

이 프로젝트가 실제로 이 패턴들을 씁니다 — src/lib/utils.tscn(clsx + tailwind-merge), src/app/global.css 의 oklch 테마 토큰과 .dark 블록(baseColor neutral, components.json), 그리고 화면 폼은 react-hook-form + zod 조합을 사용합니다. 버튼 등 복사된 컴포넌트는 src/components/ui/ 에 모여 있어요.

다음 단계