shadcn/ui 컴포넌트 배포 방식과 구조

shadcn/ui는 컴포넌트를 패키지로 설치하지 않고 CLI가 소스 파일을 프로젝트에 복사해 넣는 React 컴포넌트 모음이다. 각 컴포넌트는 headless primitive, Tailwind CSS 클래스, cva 로 만든 variant, cn 클래스 병합 함수로 구성되며 색은 CSS 변수로 바꾼다.

Seobway · · 9분

1. 문제 상황

패키지로 설치한 UI 라이브러리의 버튼을 브랜드 색으로 바꾸려고 클래스를 붙였는데 적용되지 않는다.

/* 라이브러리 CSS (node_modules 안) */
.lib-root .lib-button.lib-contained { background: #1976d2; }

/* 내 CSS */
.my-btn { background: #7c3aed; }   /* 적용되지 않는다 */

라이브러리 선택자의 명시도는 (0,3,0), 내 선택자는 (0,1,0) 이다. 이기려면 선택자를 더 길게 쓰거나 !important 를 붙여야 한다.
라이브러리 CSS 파일을 직접 고치면 다음 설치 때 원래대로 돌아간다. 컴포넌트 소스가 내 저장소 밖에 있어서 생기는 문제다.


2. 정의

2.1 코드 배포 방식

shadcn/ui 는 npm 패키지로 컴포넌트를 받지 않는다. CLI 가 레지스트리에서 컴포넌트 소스를 받아 components/ui/button.tsx 같은 파일로 프로젝트에 쓴다[1].
공식 문서는 이 방식을 "open code" 라고 부른다. 컴포넌트 코드가 처음부터 내 저장소에 있으므로 수정, 삭제, 확장을 파일 편집으로 한다.

패키지 배포 방식과 shadcn/ui 배포 방식
그림 1. 왼쪽은 node_modules 안의 컴포넌트를 바깥에서 덮어쓰는 구조, 오른쪽은 CLI가 소스를 내 저장소에 쓰는 구조다.

2.2 구성 요소

컴포넌트 파일 하나는 네 요소로 만들어진다.

요소 역할 패키지
headless primitive 키보드 조작, ARIA, 포커스 관리 radix-ui 또는 @base-ui/react
Tailwind CSS 클래스 모양 tailwindcss
cva variant 별 클래스 묶음 선택 class-variance-authority
cn() 클래스 조건부 결합과 충돌 제거 cn

components.json 의 style 값이 primitive 를 정한다. radix-nova 스타일의 버튼은 radix-ui 를, base-nova 스타일의 버튼은 @base-ui/react/button 을 불러온다.


3. 동작 원리

3.1 초기화와 추가

init 은 프로젝트 설정을 components.json 에 기록한다[3]. CLI 는 이 파일을 읽어 컴포넌트를 어디에 쓸지, import 경로를 어떻게 바꿀지 정한다.

npx shadcn@latest init
npx shadcn@latest add button
{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "base-nova",
  "tsx": true,
  "tailwind": {
    "css": "src/styles/globals.css",
    "baseColor": "neutral",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui"
  },
  "iconLibrary": "lucide"
}

add button 은 레지스트리의 button.json 을 받아 aliases.ui 경로에 button.tsx 를 쓰고, 파일 안의 import 경로를 aliases 에 맞게 바꾸고, 필요한 패키지를 설치한다.
수동 설치 문서 기준 기본 의존성은 shadcn, class-variance-authority, cn, lucide-react, tw-animate-css 다[2].

3.2 버튼 파일의 구조

레지스트리의 radix-nova 버튼을 줄인 코드다. 클래스 목록 일부와 variant 일부를 생략했다.

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium ...",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
        ghost: "hover:bg-accent hover:text-accent-foreground",
      },
      size: {
        default: "h-9 px-4 py-2 has-[>svg]:px-3",
        sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
        icon: "size-9",
      },
    },
    defaultVariants: { variant: "default", size: "default" },
  }
)

function Button({
  className,
  variant = "default",
  size = "default",
  asChild = false,
  ...props
}: React.ComponentProps<"button"> &
  VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
  const Comp = asChild ? Slot.Root : "button"

  return (
    <Comp
      data-slot="button"
      data-variant={variant}
      data-size={size}
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  )
}

export { Button, buttonVariants }
<Button asChild variant="outline">
  <a href="/docs">문서</a>
</Button>
// 렌더 결과: <a href="/docs" data-slot="button" data-variant="outline" data-size="default" class="... border bg-background ...">문서</a>

3.3 cn 이 클래스 충돌을 제거한다

cn() 은 조건부 클래스 결합과 Tailwind 클래스 충돌 제거를 한 번에 한다. 예전 버전의 lib/utils.ts 는 clsx 와 tailwind-merge 를 조합해 이 함수를 정의했고,
현재 문서는 cn 패키지를 다시 내보낸다[2].

// lib/utils.ts
export { cn } from "cn"

충돌 제거가 필요한 이유는 Tailwind CSS에서 같은 속성의 클래스 두 개가 붙으면 class 속성 순서가 아니라 생성된 CSS 순서로 결과가 정해지기 때문이다
(Tailwind CSS 유틸리티 생성 과정 → 5장). cn() 은 같은 속성을 다루는 클래스 중 뒤에 온 것만 남긴다.

인터랙티브 다이어그램: <Button variant="outline" className="px-8"> 의 class 가 정해지는 과정 (단계별로 재생할 수 있다)

  1. 사용처에서 variant="outline", className="px-8" 을 넘긴다. size 는 기본값 default 가 된다.
  2. cva 가 기본 클래스, outline 클래스, size default 클래스(h-9 px-4 py-2)를 순서대로 잇고 className(px-8)을 맨 뒤에 붙인다.
  3. cn 이 문자열을 받는다. px-4 와 px-8 은 둘 다 padding-inline 을 다룬다.
  4. 뒤에 온 px-8 만 남기고 px-4 를 지운다. py-2 처럼 충돌하지 않는 클래스는 유지한다.

3.4 테마는 CSS 변수로 바꾼다

컴포넌트 클래스에는 bg-blue-500 같은 구체적인 색 대신 bg-primary, bg-background 같은 의미 이름이 들어 있다[4].
실제 색은 전역 CSS의 변수로 정의하고, @theme inline 으로 Tailwind 유틸리티에 연결한다.

@import "tailwindcss";
@import "tw-animate-css";

@custom-variant dark (&:is(.dark *));

@theme inline {
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-background: var(--background);
}

:root {
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
}

@theme inline 은 유틸리티가 테마 변수를 거치지 않고 값을 직접 쓰게 한다. Tailwind CSS 4.3.3 으로 빌드하면 bg-primary 는 background-color: var(--primary) 가 된다.
inline 없이 같은 변수를 정의하면 var(--color-primary) 를 거치는데, 이 변수는 :root 에서 한 번 계산되므로 하위 요소에서 --primary 를 바꿔도 반영되지 않을 수 있다.
브랜드 색을 바꾸려면 컴포넌트 파일이 아니라 :root 와 .dark 의 변수 값만 바꾼다.


4. 예제

4.1 cva 와 cn 직접 실행

buttonVariants 와 같은 구조를 esm.sh 로 불러와 실행한다. 출력은 Node 에서 같은 버전으로 실행해 확인한 값과 같다.
className 에 px-8 대신 bg-red-500 을 넣거나 size: "sm" 을 넘겨 결과를 비교해 본다.

import { cva } from "https://esm.sh/class-variance-authority@0.7.1";
import { cn } from "https://esm.sh/cn@0.4.0";

const buttonVariants = cva("inline-flex items-center rounded-md text-sm font-medium", {
  variants: {
    variant: {
      default: "bg-primary text-primary-foreground hover:bg-primary/90",
      outline: "border bg-background hover:bg-accent",
    },
    size: {
      default: "h-9 px-4 py-2",
      sm: "h-8 px-3",
    },
  },
  defaultVariants: { variant: "default", size: "default" },
});

console.log(buttonVariants());
// inline-flex items-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2

console.log(cn(buttonVariants({ variant: "outline", className: "px-8" })));
// inline-flex items-center rounded-md text-sm font-medium border bg-background hover:bg-accent h-9 py-2 px-8

console.log(cn("bg-red-500", false && "bg-blue-500", { "opacity-50": true }));
// bg-red-500 opacity-50

4.2 variant 추가

복사된 파일은 내 코드이므로 새 variant 는 cva 선언에 한 줄을 추가하면 된다. VariantProps 가 타입을 다시 뽑으므로 사용처에서 바로 자동 완성된다.

button.tsx
variant: {
  default: "bg-primary text-primary-foreground hover:bg-primary/90",
  outline: "border bg-background ...",
  brand: "bg-brand text-white hover:bg-brand/90",
},
사용처
<Button variant="brand">구매</Button>

bg-brand 는 Tailwind CSS @theme 에 --color-brand 를 정의해야 생성된다.


5. 자주 하는 실수


6. 정리

shadcn/ui 는 컴포넌트 소스를 CLI 로 내 저장소에 쓰는 배포 방식이다. 컴포넌트는 headless primitive, Tailwind CSS 클래스, cva variant, cn() 병합으로 구성되고,
색은 :root 와 .dark 의 CSS 변수에서 정해진다. 스타일 변경은 바깥에서 덮어쓰지 않고 파일과 변수를 직접 고친다.

참고

  1. [1] Introduction — shadcn/ui
  2. [2] Manual Installation — shadcn/ui
  3. [3] components.json — shadcn/ui
  4. [4] Theming — shadcn/ui
  5. [5] Slot — Radix Primitives
  6. [6] Introduction — cva

관련 글