이 글은 누구를 위한 것인가
- MUI, Chakra UI의 스타일 제약에 지쳐 탈출을 고민하는 팀
- shadcn/ui가 무엇인지, npm 패키지와 어떻게 다른지 궁금한 개발자
- 디자인 시스템 구축에 Radix UI를 활용하려는 팀
들어가며
"shadcn/ui는 컴포넌트 라이브러리가 아니다." 이 문장이 혼란을 준다. shadcn/ui는 npm install로 설치하지 않는다. 컴포넌트 코드를 프로젝트에 직접 복사해서 소유권을 갖는다.
Radix UI는 스타일 없는(헤드리스) 접근성 완비 컴포넌트를 제공하고, shadcn/ui는 Radix + Tailwind CSS로 스타일을 입힌 코드 스니펫을 제공한다.
이 글은 bluefoxdev.kr의 React 디자인 시스템 구축 가이드 를 참고하고, shadcn/ui + Radix 실전 커스터마이징 관점에서 확장하여 작성했습니다.
1. shadcn/ui 방식의 장단점
[전통적 컴포넌트 라이브러리 vs shadcn/ui]
전통적 라이브러리 (MUI, Chakra):
장점: npm install 하나로 즉시 사용
단점:
❌ 스타일 오버라이드가 어렵고 번거로움
❌ 라이브러리 업데이트에 종속
❌ 번들 크기 증가 (안 쓰는 컴포넌트까지 포함)
❌ 디자인 제약 (라이브러리 스타일에서 벗어나기 힘듦)
shadcn/ui 방식:
장점:
✅ 코드 소유 — 완전한 커스터마이징
✅ 트리 쉐이킹 — 필요한 것만
✅ Radix의 접근성 내장
✅ Tailwind CSS 기반 — 디자인 토큰 연동 쉬움
✅ AI 친화적 (코드가 로컬에 있어서 수정 쉬움)
단점:
❌ 컴포넌트 업데이트는 수동
❌ 초기 설정 필요
[Radix UI의 핵심 가치]
모든 컴포넌트가 WCAG 접근성 기준 준수:
- 키보드 내비게이션 (Tab, Arrow, Escape)
- 포커스 트래핑 (다이얼로그 내부)
- ARIA 속성 자동 관리
- 스크린 리더 지원
2. 설치와 기본 설정
# Next.js 프로젝트에 shadcn/ui 초기화
npx shadcn@latest init
# 컴포넌트 추가 (코드가 프로젝트로 복사됨)
npx shadcn@latest add button
npx shadcn@latest add dialog
npx shadcn@latest add select
npx shadcn@latest add dropdown-menu
// components.json (shadcn/ui 설정)
{
"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"
}
}
/* globals.css — CSS 변수로 디자인 토큰 */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--muted: 210 40% 96.1%;
--accent: 210 40% 96.1%;
--destructive: 0 84.2% 60.2%;
--border: 214.3 31.8% 91.4%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
/* ... */
}
}
3. 컴포넌트 커스터마이징
// components/ui/button.tsx — shadcn/ui 복사 후 커스터마이징
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium 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',
link: 'text-primary underline-offset-4 hover:underline',
// ✅ 커스텀 변형 추가
gradient: 'bg-gradient-to-r from-blue-600 to-purple-600 text-white hover:opacity-90',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
xl: 'h-14 rounded-lg px-10 text-base', // ✅ 커스텀 크기 추가
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export function Button({
className,
variant,
size,
loading, // ✅ 커스텀 prop 추가
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
loading?: boolean;
}) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
disabled={loading || props.disabled}
{...props}
>
{loading ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span className="sr-only">로딩 중...</span>
</>
) : children}
</button>
);
}
4. Radix 기반 접근성 다이얼로그
// Dialog 컴포넌트 — Radix 접근성 내장 확인
import * as DialogPrimitive from '@radix-ui/react-dialog';
// Radix가 자동 처리하는 접근성:
// - Escape 키로 닫기
// - 다이얼로그 내 포커스 트래핑
// - 배경 콘텐츠에 aria-hidden
// - role="dialog", aria-modal="true" 자동 추가
function ConfirmDialog({
trigger,
title,
description,
onConfirm,
}: {
trigger: React.ReactNode;
title: string;
description: string;
onConfirm: () => void;
}) {
const [open, setOpen] = useState(false);
return (
<DialogPrimitive.Root open={open} onOpenChange={setOpen}>
<DialogPrimitive.Trigger asChild>
{trigger}
</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out" />
<DialogPrimitive.Content
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md rounded-lg bg-white p-6 shadow-xl"
aria-describedby="dialog-description"
>
<DialogPrimitive.Title className="text-lg font-semibold">
{title}
</DialogPrimitive.Title>
<DialogPrimitive.Description id="dialog-description" className="mt-2 text-sm text-gray-600">
{description}
</DialogPrimitive.Description>
<div className="mt-6 flex justify-end gap-3">
<DialogPrimitive.Close asChild>
<Button variant="outline">취소</Button>
</DialogPrimitive.Close>
<Button
variant="destructive"
onClick={() => { onConfirm(); setOpen(false); }}
>
확인
</Button>
</div>
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100"
aria-label="닫기"
>
✕
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
마무리
shadcn/ui의 핵심 가치는 "코드 소유"다. 컴포넌트 코드가 프로젝트 안에 있으므로, 디자이너가 원하는 어떤 스타일도 적용할 수 있다. Radix UI의 접근성은 무료로 따라온다.
처음에는 "설치가 왜 이렇게 복잡하지?"라고 느낄 수 있지만, 한 번 세팅하면 새 컴포넌트를 npx shadcn add로 추가하고 즉시 커스터마이징할 수 있다. 디자인 시스템을 빠르게 구축하는 가장 실용적인 방법이다.