🌐 웹개발

Radix UI

Radix Primitives

접근성 우선 headless UI 컴포넌트 라이브러리.

📖 상세 설명

Radix UI는 WorkOS에서 개발한 접근성(a11y) 중심의 headless React 컴포넌트 라이브러리입니다. Headless란 스타일이 없는 순수 기능과 로직만 제공한다는 의미로, 개발자가 완전한 디자인 자유도를 가지면서도 복잡한 접근성 요구사항을 자동으로 충족할 수 있습니다. WAI-ARIA 가이드라인을 완벽히 준수하며, 키보드 네비게이션과 포커스 관리가 내장되어 있습니다.

Radix Primitives는 Dialog, Dropdown Menu, Popover, Tooltip, Tabs, Accordion, Select 등 30개 이상의 저수준 UI 컴포넌트를 제공합니다. 각 컴포넌트는 Compound Component 패턴을 사용하여 Root, Trigger, Content 등 서브 컴포넌트로 구성됩니다. 이 패턴은 부모-자식 간 상태 공유를 Context API로 처리하여 개발자에게 유연한 구조를 제공합니다.

shadcn/ui는 Radix UI 위에 Tailwind CSS 스타일을 적용한 컴포넌트 컬렉션으로, 복사-붙여넣기 방식으로 프로젝트에 통합됩니다. 이는 패키지 의존성 없이 코드 소유권을 개발자에게 부여하는 새로운 접근법입니다. Radix + Tailwind 조합은 현대 React 프로젝트의 표준 UI 스택으로 자리잡았습니다.

Radix UI의 또 다른 특징은 비제어 컴포넌트(Uncontrolled)와 제어 컴포넌트(Controlled) 모두를 지원한다는 점입니다. 간단한 사용 시에는 기본 상태 관리를 사용하고, 복잡한 로직이 필요할 때는 외부 상태와 연동할 수 있습니다. 또한 Portal, asChild 패턴을 통해 DOM 구조 제어와 컴포넌트 합성이 가능합니다.

💻 코드 예제

Radix Dialog + Tailwind
// Radix UI Dialog 컴포넌트 with Tailwind CSS
import * as Dialog from '@radix-ui/react-dialog';
import { X } from 'lucide-react';

interface ModalProps {
    open: boolean;
    onOpenChange: (open: boolean) => void;
    title: string;
    description?: string;
    children: React.ReactNode;
}

export function Modal({ open, onOpenChange, title, description, children }: ModalProps) {
    return (
        <Dialog.Root open={open} onOpenChange={onOpenChange}>
            <Dialog.Portal>
                {/* Overlay - 배경 어둡게 */}
                <Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-fadeIn" />

                {/* Content - 실제 모달 */}
                <Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
                    w-full max-w-md bg-white rounded-lg shadow-xl p-6
                    data-[state=open]:animate-contentShow
                    focus:outline-none">

                    {/* 닫기 버튼 */}
                    <Dialog.Close className="absolute right-4 top-4 rounded-sm opacity-70
                        hover:opacity-100 focus:outline-none focus:ring-2
                        focus:ring-slate-400 focus:ring-offset-2">
                        <X className="h-4 w-4" />
                        <span className="sr-only">닫기</span>
                    </Dialog.Close>

                    {/* 제목 - 스크린 리더가 읽음 */}
                    <Dialog.Title className="text-lg font-semibold text-gray-900">
                        {title}
                    </Dialog.Title>

                    {/* 설명 - 선택적 */}
                    {description && (
                        <Dialog.Description className="mt-2 text-sm text-gray-500">
                            {description}
                        </Dialog.Description>
                    )}

                    {/* 내용 */}
                    <div className="mt-4">
                        {children}
                    </div>
                </Dialog.Content>
            </Dialog.Portal>
        </Dialog.Root>
    );
}

// 사용 예시
function App() {
    const [open, setOpen] = useState(false);

    return (
        <>
            <button onClick={() => setOpen(true)}>모달 열기</button>

            <Modal
                open={open}
                onOpenChange={setOpen}
                title="회원가입"
                description="아래 정보를 입력해주세요."
            >
                <form className="space-y-4">
                    <input type="email" placeholder="이메일" className="w-full border p-2 rounded" />
                    <input type="password" placeholder="비밀번호" className="w-full border p-2 rounded" />
                    <button type="submit" className="w-full bg-blue-500 text-white p-2 rounded">
                        가입하기
                    </button>
                </form>
            </Modal>
        </>
    );
}
Radix Dropdown Menu
// Radix UI Dropdown Menu
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { ChevronRight, User, Settings, LogOut } from 'lucide-react';

export function UserMenu() {
    return (
        <DropdownMenu.Root>
            {/* Trigger - 메뉴를 열 버튼 */}
            <DropdownMenu.Trigger asChild>
                <button className="flex items-center gap-2 px-3 py-2 rounded-lg
                    hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500">
                    <img src="/avatar.jpg" className="w-8 h-8 rounded-full" alt="프로필" />
                    <span>김철수</span>
                </button>
            </DropdownMenu.Trigger>

            <DropdownMenu.Portal>
                <DropdownMenu.Content
                    className="min-w-[200px] bg-white rounded-lg shadow-lg border p-1
                        data-[state=open]:animate-slideDownAndFade"
                    sideOffset={5}
                    align="end"
                >
                    {/* 일반 메뉴 아이템 */}
                    <DropdownMenu.Item className="flex items-center gap-2 px-3 py-2 rounded
                        cursor-pointer hover:bg-gray-100 focus:bg-gray-100 outline-none">
                        <User className="w-4 h-4" />
                        프로필
                    </DropdownMenu.Item>

                    {/* 서브 메뉴 */}
                    <DropdownMenu.Sub>
                        <DropdownMenu.SubTrigger className="flex items-center justify-between
                            px-3 py-2 rounded cursor-pointer hover:bg-gray-100 outline-none">
                            <div className="flex items-center gap-2">
                                <Settings className="w-4 h-4" />
                                설정
                            </div>
                            <ChevronRight className="w-4 h-4" />
                        </DropdownMenu.SubTrigger>

                        <DropdownMenu.Portal>
                            <DropdownMenu.SubContent className="min-w-[160px] bg-white
                                rounded-lg shadow-lg border p-1">
                                <DropdownMenu.Item className="px-3 py-2 rounded cursor-pointer
                                    hover:bg-gray-100 outline-none">
                                    계정 설정
                                </DropdownMenu.Item>
                                <DropdownMenu.Item className="px-3 py-2 rounded cursor-pointer
                                    hover:bg-gray-100 outline-none">
                                    알림 설정
                                </DropdownMenu.Item>
                            </DropdownMenu.SubContent>
                        </DropdownMenu.Portal>
                    </DropdownMenu.Sub>

                    {/* 구분선 */}
                    <DropdownMenu.Separator className="h-px bg-gray-200 my-1" />

                    {/* 로그아웃 - 위험 동작 스타일 */}
                    <DropdownMenu.Item className="flex items-center gap-2 px-3 py-2 rounded
                        cursor-pointer text-red-600 hover:bg-red-50 outline-none">
                        <LogOut className="w-4 h-4" />
                        로그아웃
                    </DropdownMenu.Item>

                    {/* 화살표 */}
                    <DropdownMenu.Arrow className="fill-white" />
                </DropdownMenu.Content>
            </DropdownMenu.Portal>
        </DropdownMenu.Root>
    );
}

🗣️ 실무 대화 예시

💼 UI 라이브러리 선택 회의
"MUI랑 Radix 중에 뭘 선택해야 할까요? MUI가 컴포넌트가 더 많은 것 같은데요."

"MUI는 Material Design 스타일이 강제되고 커스터마이징이 어려워요. Radix는 headless라서 디자인 시스템에 맞게 자유롭게 스타일링 가능해요. shadcn/ui 쓰면 Tailwind로 빠르게 시작할 수 있고, 필요하면 코드를 직접 수정할 수도 있어서 요즘 많이 선호해요."
🔍 코드 리뷰
"모달이 열릴 때 배경 스크롤이 안 막히고 포커스 트랩도 안 되는데요."

"직접 만든 모달이라 그래요. Radix Dialog 쓰면 scroll lock, focus trap, ESC로 닫기, aria-modal 속성까지 다 자동이에요. 접근성 직접 구현하면 놓치는 게 많아서 검증된 라이브러리 쓰는 게 좋아요."
📱 기술 면접
"Headless UI 컴포넌트의 장점이 뭔가요?"

"스타일과 로직이 분리되어서 디자인 시스템에 맞게 완전히 커스터마이징할 수 있어요. Bootstrap이나 MUI처럼 프레임워크 스타일을 override하느라 CSS specificity 싸움할 필요가 없죠. Radix는 접근성까지 내장되어 있어서 WCAG 준수가 자동으로 돼요."

⚠️ 주의사항

  • 스타일링 필수 - Radix Primitives는 스타일이 전혀 없어서 직접 CSS를 작성해야 합니다. 빠른 시작을 원하면 shadcn/ui나 Radix Themes를 사용하세요.
  • React 전용 - Radix UI는 React에서만 동작합니다. Vue, Svelte에서는 Headless UI(@headlessui/vue) 등 대안을 찾아야 합니다.
  • 애니메이션 직접 구현 - 열기/닫기 애니메이션은 data-state 속성과 CSS 트랜지션 또는 Framer Motion으로 직접 구현해야 합니다.

🔗 관련 용어

📚 더 배우기