개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← 글 목록

shadcn/ui — Radix UI와 Tailwind 기반 재사용 컴포넌트

2026년 6월 8일
ReactTailwind CSSRadix UIshadcn/ui

대부분의 UI 컴포넌트 라이브러리는 npm 패키지로 설치합니다. 그러나 이 방식에는 한계가 있습니다. 내부 구현을 수정하려면 패키지를 포크하거나 복잡한 오버라이드가 필요합니다. 버전 업데이트 시 의도치 않은 변경이 생기기도 합니다.

shadcn/ui는 다른 방식을 선택합니다. npm에 설치하는 것이 아니라, CLI로 컴포넌트 소스 코드를 프로젝트에 직접 복사합니다. 복사된 코드는 완전히 내 것이며 자유롭게 수정할 수 있습니다. Radix UI의 접근성과 Tailwind CSS의 스타일링, class-variance-authority의 변형 관리를 조합한 고품질 컴포넌트를 바탕으로 삼습니다.

초기 설정

npx shadcn@latest init

이 명령은 몇 가지 질문 후 components.json 설정 파일을 생성하고, cn 유틸리티와 기본 CSS 변수를 설정합니다.

// components.json
{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "default",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.ts",
    "css": "app/globals.css",
    "baseColor": "slate",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils"
  }
}

1. CLI

shadcn/ui의 핵심 특징은 패키지 설치 대신 소스 코드 복사입니다. CLI가 레지스트리에서 컴포넌트 코드를 가져와 프로젝트 디렉터리에 씁니다.

# 단일 컴포넌트 추가
npx shadcn@latest add button
npx shadcn@latest add dialog
npx shadcn@latest add input

# 복수 컴포넌트 한 번에 추가
npx shadcn@latest add button dialog input select

# 모든 컴포넌트 추가
npx shadcn@latest add --all

명령 실행 후 src/components/ui/button.tsx 같은 파일이 생성됩니다. 이 파일은 직접 편집할 수 있습니다. shadcn/ui는 이 파일을 다시 덮어쓰지 않습니다.

  CLI 실행
     |
     v
  레지스트리에서 소스 가져옴
     |
     v
  필요한 Radix 패키지 npm install
     |
     v
  src/components/ui/button.tsx 생성 (내 코드)

2. Registry

shadcn/ui 레지스트리는 각 컴포넌트의 소스, 필요한 npm 패키지, 스타일 등의 메타데이터를 관리합니다. CLI는 이 레지스트리를 참조하여 컴포넌트를 가져옵니다.

// 레지스트리 항목 구조 예시
{
  "name": "button",
  "type": "registry:ui",
  "dependencies": ["@radix-ui/react-slot"],
  "devDependencies": [],
  "files": [
    {
      "path": "ui/button.tsx",
      "content": "..."
    }
  ]
}

커스텀 레지스트리를 구성하여 팀 내부 컴포넌트를 배포하는 것도 가능합니다.

# 커스텀 레지스트리에서 컴포넌트 추가
npx shadcn@latest add https://my-registry.com/components/custom-chart.json

3. Component

shadcn/ui 컴포넌트는 Radix Primitive 위에 Tailwind 클래스를 씌운 구조입니다. 추가 후 소스 코드를 직접 확인하고 수정할 수 있습니다.

// 추가된 Button 컴포넌트 (src/components/ui/button.tsx)
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium " +
  "ring-offset-background transition-colors focus-visible:outline-none " +
  "focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      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 bg-background hover:bg-accent",
        ghost: "hover:bg-accent hover:text-accent-foreground",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button";
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    );
  }
);

4. Variant

cva는 컴포넌트 변형을 체계적으로 관리합니다. 기본 클래스와 변형 옵션을 분리하여 선언하고, TypeScript 타입이 자동으로 생성됩니다.

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

const alertVariants = cva(
  // 기본 클래스 — 모든 변형에 공통 적용
  "relative w-full rounded-lg border p-4 flex gap-3",
  {
    variants: {
      variant: {
        default: "bg-background text-foreground border-border",
        success: "bg-green-50 text-green-900 border-green-200",
        warning: "bg-yellow-50 text-yellow-900 border-yellow-200",
        destructive: "bg-red-50 text-red-900 border-red-200",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
);

interface AlertProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof alertVariants> {}

function Alert({ className, variant, ...props }: AlertProps) {
  return (
    <div className={cn(alertVariants({ variant }), className)} {...props} />
  );
}

// 사용 예시
<Alert variant="success">저장되었습니다.</Alert>
<Alert variant="destructive">오류가 발생했습니다.</Alert>

5. Theme

shadcn/ui는 Tailwind 클래스를 하드코딩하는 대신 CSS 변수를 사용합니다. 변수 값만 바꾸면 모든 컴포넌트 색상이 함께 바뀝니다.

/* globals.css */
@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --primary: 222.2 47.4% 11.2%;
    --primary-foreground: 210 40% 98%;
    --secondary: 210 40% 96.1%;
    --secondary-foreground: 222.2 47.4% 11.2%;
    --muted: 210 40% 96.1%;
    --muted-foreground: 215.4 16.3% 46.9%;
    --accent: 210 40% 96.1%;
    --destructive: 0 84.2% 60.2%;
    --border: 214.3 31.8% 91.4%;
    --ring: 222.2 84% 4.9%;
    --radius: 0.5rem;
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --primary: 210 40% 98%;
    --primary-foreground: 222.2 47.4% 11.2%;
    /* ... */
  }
}
// tailwind.config.ts — CSS 변수를 Tailwind에 연결
theme: {
  extend: {
    colors: {
      background: "hsl(var(--background))",
      foreground: "hsl(var(--foreground))",
      primary: {
        DEFAULT: "hsl(var(--primary))",
        foreground: "hsl(var(--primary-foreground))",
      },
    },
  },
}

이 구조 덕분에 bg-primary는 :root의 --primary 값을 따릅니다. 다크 모드에서는 .dark에 정의된 변수 값으로 자동 전환됩니다.

shadcn/ui 공식 사이트에서 테마를 시각적으로 선택하고 CSS 변수를 복사할 수 있습니다.


6. Composition

shadcn/ui 컴포넌트는 대부분 asChild prop을 지원합니다. 이를 통해 HTML 요소나 다른 컴포넌트와 자연스럽게 조합됩니다.

import { Button } from "@/components/ui/button";
import { Link } from "react-router-dom";

// 기본 — <button> 렌더링
<Button variant="default" size="lg">
  클릭
</Button>

// asChild — <a> 태그에 Button 스타일 적용
<Button asChild variant="outline">
  <a href="https://example.com" target="_blank">
    외부 링크
  </a>
</Button>

// asChild — React Router Link에 Button 스타일 적용
<Button asChild>
  <Link to="/dashboard">대시보드</Link>
</Button>

복잡한 컴포넌트도 합성으로 조합합니다.

import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

function EditProfileDialog() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="outline">프로필 편집</Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>프로필 편집</DialogTitle>
        </DialogHeader>
        <div className="grid gap-4 py-4">
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="name" className="text-right">이름</Label>
            <Input id="name" className="col-span-3" />
          </div>
        </div>
        <DialogFooter>
          <Button type="submit">저장</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

7. Accessibility

shadcn/ui 컴포넌트는 Radix Primitive 위에 구축되어 있으므로, Radix가 처리하는 모든 접근성이 자동으로 포함됩니다.

// Select 컴포넌트 예시 — 키보드 탐색, aria-* 자동 처리
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

function CountrySelect() {
  return (
    <Select>
      {/* 자동으로 aria-haspopup, aria-expanded 처리 */}
      <SelectTrigger className="w-[180px]">
        <SelectValue placeholder="국가 선택" />
      </SelectTrigger>
      <SelectContent>
        {/* Arrow Key 탐색, Enter/Space 선택 자동 처리 */}
        <SelectItem value="kr">대한민국</SelectItem>
        <SelectItem value="us">미국</SelectItem>
        <SelectItem value="jp">일본</SelectItem>
      </SelectContent>
    </Select>
  );
}

추가적으로 shadcn/ui 컴포넌트는 Label과 폼 요소의 연결, aria-describedby를 통한 오류 메시지 연결 등을 편리하게 구성할 수 있는 구조를 제공합니다.

<div className="grid w-full max-w-sm items-center gap-1.5">
  <Label htmlFor="email">이메일</Label>
  <Input
    type="email"
    id="email"
    placeholder="example@email.com"
    aria-describedby="email-error"
  />
  <p id="email-error" className="text-sm text-destructive">
    유효한 이메일 주소를 입력해 주세요.
  </p>
</div>

정리

shadcn/ui는 컴포넌트 라이브러리가 아닙니다. 공식 문서의 표현처럼 "복사해서 붙여넣는 컴포넌트 컬렉션"입니다.

개념역할
CLI소스 코드 복사 도구, npm install 대신 파일을 프로젝트에 씀
Registry컴포넌트 소스와 의존성 정보 저장소
ComponentRadix + Tailwind로 조합된 완성형 UI 컴포넌트
Variantcva로 타입 안전하게 정의한 컴포넌트 변형
ThemeCSS 변수로 전체 색상 팔레트를 한 번에 교체
CompositionasChild로 동작을 자식 요소에 위임
AccessibilityRadix 기반으로 WAI-ARIA 자동 보장

코드가 내 프로젝트 안에 있기 때문에 외부 패키지 업데이트에 영향을 받지 않습니다. 필요한 컴포넌트만 선택하고, 자유롭게 수정하며, 팀의 디자인 시스템에 맞게 발전시킬 수 있습니다.

← 이전 글Radix UI — 접근성을 고려한 Headless 컴포넌트
다음 글 →Storybook — UI 컴포넌트를 독립적으로 개발하고 문서화하는 도구