React UI 라이브러리를 고르는 건 늘 고민이다. MUI처럼 완성된 디자인 시스템을 쓰면 빠르게 시작할 수 있지만, 커스터마이징할 때마다 기본 스타일과 싸워야 한다. 직접 만들자니 접근성, 키보드 네비게이션, 포커스 관리까지 신경 쓸 게 너무 많다.
pixelDiff를 만들면서 이 딜레마를 다시 마주했다. 결론부터 말하면 shadcn/ui를 선택했고, 7000줄이 넘는 UI 컴포넌트를 쌓아가면서 이게 맞는 선택이었다는 확신이 들었다.
UI 라이브러리 선택지
검토했던 세 가지 선택지다.
| 라이브러리 | 스타일링 방식 | 커스터마이징 | 번들 크기 | 특징 |
|---|---|---|---|---|
| MUI | Emotion (CSS-in-JS) | 테마 오버라이드 | 큼 | 완성된 디자인 시스템, Google Material Design |
| Chakra UI | Emotion | 테마 토큰 | 중간 | 직관적인 props, 좋은 DX |
| shadcn/ui | Tailwind CSS | 코드 직접 수정 | 작음 | 복사해서 쓰는 컴포넌트, Radix 기반 |
MUI와 Chakra는 "라이브러리"다. npm install 하면 node_modules에 들어가고, 업데이트하면 버전이 올라간다. 반면 shadcn/ui는 "복사해서 쓰는 코드"다. CLI로 컴포넌트를 추가하면 내 프로젝트 폴더에 .tsx 파일이 생긴다.
이 차이가 생각보다 크다.
shadcn/ui를 선택한 이유
첫째, 코드가 내 것이 된다.
MUI에서 Button 스타일을 바꾸려면 sx prop이나 styled()로 기본 스타일을 덮어써야 한다. 특정 상태에서만 다르게 보이게 하려면 문서를 뒤지며 어떤 클래스가 어떤 상태인지 파악해야 한다.
shadcn/ui는 button.tsx 파일을 열어서 직접 고치면 된다. 기본 스타일이 어떻게 정의되어 있는지 눈에 보이고, 원하는 대로 수정하면 그게 전부다.
둘째, Radix UI의 접근성을 그대로 쓴다.
Headless UI의 핵심은 "스타일 없이 동작만 제공하는 것"이다. Radix UI는 Dialog, Dropdown, Tooltip 같은 복잡한 컴포넌트의 키보드 네비게이션, 포커스 트랩, ARIA 속성을 모두 처리해준다.
shadcn/ui는 Radix를 기반으로 Tailwind 스타일만 얹은 구조다. 접근성은 Radix가 책임지고, 스타일은 내가 제어한다.
// Dialog 컴포넌트 구조
import * as DialogPrimitive from '@radix-ui/react-dialog';
const Dialog = DialogPrimitive.Root; // 상태 관리
const DialogTrigger = DialogPrimitive.Trigger; // 열기 버튼
const DialogContent = DialogPrimitive.Content; // 본문 (포커스 트랩 포함)
Radix가 상태와 접근성을 담당하고, 나는 DialogContent에 Tailwind 클래스만 붙이면 된다.
셋째, Tailwind와 자연스럽게 어울린다.
이미 Tailwind를 쓰고 있다면 shadcn/ui는 기존 워크플로우에 그대로 녹아든다. 별도의 테마 설정 파일을 관리할 필요 없이 tailwind.config.js에서 모든 디자인 토큰을 관리한다.
// tailwind.config.js
colors: {
'brand-accent': 'hsl(var(--brand-accent))',
'brand-accent-foreground': 'hsl(var(--brand-accent-foreground))',
}
CSS 변수와 Tailwind를 조합하면 다크 모드 전환도 간단하다.
커스터마이징 예시: Button에 리플 효과 추가
shadcn/ui의 기본 Button은 깔끔하지만 밋밋하다. Material Design 스타일의 리플(ripple) 효과를 추가했다.

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, onClick, children, ...props }, ref) => {
const [ripples, setRipples] = React.useState<Ripple[]>([]);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const createRipple = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
const button = buttonRef.current;
if (!button) return;
const rect = button.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const newRipple = { id: Date.now(), x, y };
setRipples((prev) => [...prev, newRipple]);
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== newRipple.id));
}, 600);
},
[]
);
return (
<button
className={cn(buttonVariants({ variant, size, className }), 'relative overflow-hidden')}
ref={buttonRef}
onClick={(e) => { createRipple(e); onClick?.(e); }}
{...props}
>
{children}
<AnimatePresence>
{ripples.map((ripple) => (
<motion.span
key={ripple.id}
initial={{ scale: 0, opacity: 0.4 }}
animate={{ scale: 10, opacity: 0 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className="pointer-events-none absolute rounded-full bg-white/30"
style={{ width: 20, height: 20, top: ripple.y - 10, left: ripple.x - 10 }}
/>
))}
</AnimatePresence>
</button>
);
}
);
클릭 위치를 계산해서 그 지점에서 원이 퍼져나가는 애니메이션이다. Framer Motion을 썼지만, CSS 애니메이션으로도 충분히 구현 가능하다.
MUI였다면? ripple prop이 이미 있지만, 색상이나 속도를 바꾸려면 테마 오버라이드를 파고들어야 한다. shadcn/ui에서는 내 코드니까 원하는 대로 수정하면 된다.
커스터마이징 예시: Tooltip 확장
기본 Tooltip에 몇 가지 기능을 추가했다.

interface TooltipContentProps {
variant?: 'default' | 'card';
showArrow?: boolean;
dismissible?: boolean;
onDismiss?: () => void;
shortcut?: string; // 단축키 표시
disabled?: boolean; // 비활성 상태 메시지
}
단축키 표시: shortcut="⌘+S" 형태로 전달하면 툴팁에 키보드 단축키가 함께 표시된다.
const renderShortcut = (key: string) => {
const symbolOffset = { '⌘': 'translate-y-[1px]', '⇧': '-translate-y-[0.5px]' };
return (
<kbd className="inline-flex items-center justify-center bg-black/20 text-white/80 font-medium rounded min-w-[18px] h-[18px] px-1">
<span className={symbolOffset[key] || 'text-[10px]'}>{key}</span>
</kbd>
);
};
비활성 상태: disabled={true}면 "원본에서만 사용 가능" 같은 안내 메시지가 추가된다.
if (disabled) {
return (
<div className="flex flex-col gap-0.5">
<span>{mainContent}</span>
<span className="text-white/60 text-[11px]">원본에서만 사용 가능</span>
</div>
);
}
이런 확장이 가능한 이유는 TooltipContent가 내 코드이기 때문이다. props를 추가하고, 렌더링 로직을 바꾸고, 스타일을 조정하는 게 자유롭다.
핵심 유틸리티: cn() 함수
shadcn/ui의 모든 컴포넌트에서 쓰이는 유틸리티다.
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
clsx는 조건부 클래스를 깔끔하게 조합하고, twMerge는 Tailwind 클래스 충돌을 해결한다.
// 예: p-4와 p-2가 충돌하면 p-2가 적용됨
<div className={cn('p-4 text-white', someCondition && 'p-2 bg-black')}>
6줄짜리 함수지만 없으면 불편하다.
CVA로 Variant 관리
Class Variance Authority(CVA)는 컴포넌트 variant를 타입 안전하게 관리한다.
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium',
{
variants: {
variant: {
default: 'bg-brand-accent text-white hover:bg-brand-accent/90',
destructive: 'bg-destructive text-white 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 px-3',
lg: 'h-11 px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
variant와 size의 조합이 타입으로 보장되고, 자동완성도 된다. Tailwind 클래스를 문자열로 관리할 때의 실수를 줄여준다.
shadcn/ui의 본질은 "Headless UI + 복사해서 쓰는 코드"다.
Radix가 접근성과 상태 관리를 책임지고, Tailwind가 스타일링을 담당하고, 나는 둘 사이에서 원하는 대로 조합한다. 라이브러리 버전에 종속되지 않고, 기본 스타일과 싸우지 않고, 내 코드로 제어할 수 있다.
단점도 있다. 컴포넌트를 추가할 때마다 파일이 늘어나고, Radix가 업데이트되면 직접 반영해야 한다. 하지만 "완성된 디자인 시스템이 필요한 프로젝트"가 아니라면, 이 트레이드오프는 충분히 감수할 만하다.
UI 라이브러리를 고민 중이라면: 커스터마이징을 얼마나 할 것인지를 먼저 생각해보자. 기본 디자인 그대로 쓸 거라면 MUI가 빠르다. 하지만 브랜드에 맞게 하나하나 손보고 싶다면, shadcn/ui가 답이 될 수 있다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'FrontEnd' 카테고리의 다른 글
| Spring Physics로 오뚜기 애니메이션 구현하기 (0) | 2026.03.10 |
|---|---|
| Marquee Selection (범위 선택) 구현하기 (0) | 2026.02.21 |
| Pixi.js 다중 선택 드래그 구현하기 (0) | 2026.02.19 |
| OffscreenCanvas로 Service Worker에서 이미지 처리하기 (0) | 2026.02.10 |
| next-intl 없이 i18n 직접 만들기 (0) | 2026.02.07 |
