shadcn/ui + Radix UI: 헤드리스 컴포넌트 전략과 커스터마이징

웹 개발

shadcn/uiRadix UI헤드리스 컴포넌트디자인 시스템React

이 글은 누구를 위한 것인가

  • 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로 추가하고 즉시 커스터마이징할 수 있다. 디자인 시스템을 빠르게 구축하는 가장 실용적인 방법이다.