Claude Code와 Radix UI로 접근성 있는 React UI 만들기
Claude Code와 Radix UI로 Dialog, Dropdown, Tabs를 구현하는 방법을 설치, 코드, 스타일, 함정, 체크리스트와 함께 정리합니다.
React에서 모달, 드롭다운, 탭을 직접 만들면 겉모습은 금방 완성됩니다. 하지만 실제로 어려운 부분은 키보드 탐색, 포커스 이동, 스크린 리더가 읽을 이름, Escape 동작, 모바일 너비입니다. 마우스로는 문제없어 보이는데 키보드로는 닫을 수 없거나, 닫은 뒤 포커스가 페이지 맨 위로 튀는 문제가 자주 생깁니다.
Radix UI는 이런 UI를 처음부터 다시 만들지 않기 위한 좋은 기반입니다. Radix는 스타일이 없는 primitives를 제공합니다. 즉 색상, 여백, 타이포그래피는 직접 정하지만 Dialog, Dropdown Menu, Tabs 같은 상호작용의 기본 동작은 검증된 부품을 사용합니다.
Claude Code와도 잘 맞습니다. “모달을 예쁘게 만들어줘”라고 요청하기보다 “Radix UI를 사용하고 접근성 동작을 유지하며 기존 디자인에 맞춰줘”라고 요청하면, 생성된 차이가 작고 리뷰하기 쉬워집니다. Radix 기반의 더 높은 수준 컴포넌트를 원한다면 Claude Code shadcn/ui 가이드를 참고하세요. 접근성 전반은 Claude Code 접근성 구현 가이드와 함께 보면 좋습니다.
Radix UI가 Claude Code에 맞는 이유
Radix 공식 문서는 Primitives를 접근성, 커스터마이징, 개발 경험에 초점을 둔 낮은 수준의 UI 컴포넌트 라이브러리로 설명합니다. 핵심은 완성된 디자인을 가져오는 것이 아니라, 역할, 포커스 관리, 키보드 동작, 컴포넌트 구조를 빌려오는 것입니다.
Dialog는 대표적인 예입니다. Radix Dialog는 modal과 non-modal을 지원하고, modal에서는 포커스를 내부에 가두며, Escape로 닫을 수 있고, Title과 Description으로 스크린 리더 안내를 돕습니다. Tabs는 WAI-ARIA tabs pattern을 따르며 방향키, Home, End를 처리합니다. Dropdown Menu는 라벨, 구분선, 라디오 항목을 조합할 수 있습니다.
Claude Code는 코드를 읽고, 수정하고, 검증하는 루프를 반복합니다. div만으로 복잡한 동작을 만들게 하면 접근성 로직과 스타일을 동시에 만들어야 하므로 결과가 길어지고 위험해집니다. Radix UI를 지정하면 Claude Code는 상태 연결, API 호출, CSS 클래스, 분석 이벤트, 테스트 관점에 집중할 수 있습니다.
flowchart LR
A["Claude Code에 UI 요구사항 전달"] --> B["Radix UI로 동작 구현"]
B --> C["React 상태와 업무 로직 연결"]
C --> D["프로젝트 CSS와 토큰 적용"]
D --> E["키보드, 스크린 리더, 모바일 확인"]
설치 명령
Radix 문서에는 통합 radix-ui 패키지도 소개되어 있지만, 기존 React 프로젝트에서는 개별 패키지를 쓰는 경우가 많습니다. 이 글의 예시는 어떤 primitive를 쓰는지 명확히 보이도록 개별 패키지를 사용합니다.
npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-tabs
pnpm을 쓰면 다음과 같습니다.
pnpm add @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-tabs
Claude Code가 아직 없다면 공식 getting started 문서를 먼저 확인하세요. npm 설치는 다음 형태입니다.
npm install -g @anthropic-ai/claude-code
팀 환경에서는 전역 설치를 가볍게 보면 안 됩니다. 업데이트 방식, 권한, CI에서 실행할 범위, 생성 코드 리뷰 책임을 먼저 정해야 합니다.
Claude Code에 줄 프롬프트
좋은 프롬프트는 “예쁜 UI”가 아니라 지켜야 할 동작을 적습니다.
claude "기존 React + TypeScript 화면에 확인 Dialog, 사용자 Dropdown Menu, 설정 Tabs를 추가해 주세요.
조건:
- @radix-ui/react-dialog, @radix-ui/react-dropdown-menu, @radix-ui/react-tabs 사용
- Dialog.Title과 Dialog.Description 유지
- 아이콘만 있는 닫기 버튼에는 aria-label 추가
- focus 표시를 없애지 말고 :focus-visible로 보이게 유지
- 기존 design token이 있으면 우선 사용
- 작업 후 keyboard, mobile, typecheck, accessibility 체크 항목을 알려주세요"
결과를 받은 뒤에는 스크린샷만 보지 말고 diff를 봅니다. asChild 때문에 버튼 안에 버튼이 들어가지 않았는지, 시각적으로 숨기려다 Dialog.Title을 지우지 않았는지, CSS가 focus outline을 제거하지 않았는지 확인합니다.
복사해서 쓰는 React 예제
아래 예제는 확인 다이얼로그, 사용자 메뉴, 설정 탭을 한 파일에 모은 것입니다. Vite, 일반 React SPA, Next.js Client Component에서 사용할 수 있습니다. Next.js App Router에서는 파일 맨 위에 "use client";를 추가하세요.
import * as React from "react";
import * as Dialog from "@radix-ui/react-dialog";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Tabs from "@radix-ui/react-tabs";
import "./radix-accessible-demo.css";
type User = { name: string; email: string };
type ConfirmDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmLabel?: string;
danger?: boolean;
onConfirm: () => Promise<void> | void;
};
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = "Confirm",
danger = false,
onConfirm,
}: ConfirmDialogProps) {
const [pending, setPending] = React.useState(false);
async function handleConfirm() {
setPending(true);
try {
await onConfirm();
onOpenChange(false);
} finally {
setPending(false);
}
}
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="radix-overlay" />
<Dialog.Content className="radix-dialog">
<Dialog.Title className="radix-dialog-title">{title}</Dialog.Title>
<Dialog.Description className="radix-dialog-description">
{description}
</Dialog.Description>
<div className="button-row">
<Dialog.Close asChild>
<button type="button" className="button secondary">Cancel</button>
</Dialog.Close>
<button
type="button"
className={`button ${danger ? "danger" : "primary"}`}
disabled={pending}
onClick={handleConfirm}
>
{pending ? "Working..." : confirmLabel}
</button>
</div>
<Dialog.Close asChild>
<button type="button" className="icon-button" aria-label="Close dialog">
x
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
export function UserMenu({
user,
onOpenProfile,
onOpenBilling,
onSignOut,
}: {
user: User;
onOpenProfile: () => void;
onOpenBilling: () => void;
onSignOut: () => void;
}) {
const [theme, setTheme] = React.useState<"light" | "dark" | "system">("system");
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button type="button" className="user-trigger" aria-label={`${user.name} menu`}>
<span className="avatar" aria-hidden="true">{user.name.slice(0, 1)}</span>
<span>{user.name}</span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="dropdown-content" align="end" sideOffset={8}>
<DropdownMenu.Label className="dropdown-label">{user.email}</DropdownMenu.Label>
<DropdownMenu.Separator className="dropdown-separator" />
<DropdownMenu.Item className="dropdown-item" onSelect={() => onOpenProfile()}>
Profile
</DropdownMenu.Item>
<DropdownMenu.Item className="dropdown-item" onSelect={() => onOpenBilling()}>
Billing
</DropdownMenu.Item>
<DropdownMenu.Separator className="dropdown-separator" />
<DropdownMenu.RadioGroup
value={theme}
onValueChange={(value) => setTheme(value as "light" | "dark" | "system")}
>
<DropdownMenu.RadioItem className="dropdown-item" value="light">Light</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem className="dropdown-item" value="dark">Dark</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem className="dropdown-item" value="system">System</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator className="dropdown-separator" />
<DropdownMenu.Item className="dropdown-item danger-text" onSelect={() => onSignOut()}>
Sign out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
export function SettingsTabs() {
return (
<Tabs.Root defaultValue="profile" className="tabs-root">
<Tabs.List className="tabs-list" aria-label="Account settings">
<Tabs.Trigger className="tabs-trigger" value="profile">Profile</Tabs.Trigger>
<Tabs.Trigger className="tabs-trigger" value="security">Security</Tabs.Trigger>
<Tabs.Trigger className="tabs-trigger" value="notifications">Notifications</Tabs.Trigger>
</Tabs.List>
<Tabs.Content className="tabs-content" value="profile">
<label className="field">
<span>Display name</span>
<input defaultValue="Masa" />
</label>
</Tabs.Content>
<Tabs.Content className="tabs-content" value="security">
<p>Require two-factor authentication before changing billing settings.</p>
<button type="button" className="button secondary">Review security</button>
</Tabs.Content>
<Tabs.Content className="tabs-content" value="notifications">
<label className="check-row">
<input type="checkbox" defaultChecked />
<span>Email me when a project is exported.</span>
</label>
</Tabs.Content>
</Tabs.Root>
);
}
스타일링 기준
Radix UI는 스타일을 제공하지 않습니다. Tailwind, CSS Modules, 일반 CSS, design token 중 무엇이든 사용할 수 있습니다. 중요한 것은 focus 표시를 없애지 않고, 모바일에서 Dialog가 화면 밖으로 나가지 않으며, 메뉴 레이어가 다른 UI와 충돌하지 않게 하는 것입니다.
.radix-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
}
.radix-dialog {
position: fixed;
left: 50%;
top: 50%;
width: min(calc(100vw - 32px), 480px);
max-height: calc(100vh - 32px);
overflow: auto;
transform: translate(-50%, -50%);
border-radius: 8px;
background: white;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.28);
padding: 24px;
}
.button-row {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.dropdown-content {
min-width: 220px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: white;
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.18);
padding: 6px;
}
.dropdown-item {
border-radius: 6px;
cursor: pointer;
outline: none;
padding: 8px 10px;
}
.dropdown-item[data-highlighted] {
background: #eff6ff;
color: #1d4ed8;
}
.tabs-list {
display: flex;
border-bottom: 1px solid #e2e8f0;
gap: 4px;
}
.tabs-trigger {
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
cursor: pointer;
padding: 10px 12px;
}
.tabs-trigger[data-state="active"] {
border-color: #2563eb;
color: #1d4ed8;
font-weight: 700;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 2px;
}
3가지 사용 사례
| 사용 사례 | Radix UI의 장점 | Claude Code가 연결할 부분 |
|---|---|---|
| 프로젝트 삭제, 구독 취소 확인 Dialog | 포커스와 읽기 이름을 안정적으로 관리 | API 호출, pending 상태, 에러 메시지, 테스트 |
| 계정 메뉴 | 키보드 이동, 구분선, 라디오 항목을 쉽게 유지 | 사용자 데이터, 로그아웃, 결제 페이지, analytics |
| 설정 Tabs | tablist, tab, tabpanel 관계를 유지 | 폼 분리, 저장 범위, URL 동기화, dirty 상태 |
첫 번째는 SaaS 관리자 화면입니다. 삭제나 권한 변경은 멋진 애니메이션보다 사용자가 결과를 정확히 이해하는 것이 중요합니다. Radix Dialog로 기본 동작을 맡기고, Claude Code에는 비즈니스 설명과 API 연결을 맡깁니다.
두 번째는 교육 사이트나 멤버십 서비스입니다. 계정 메뉴에 프로필, 다운로드, 구매 내역, 학습 진도, 로그아웃을 넣을 때 click-only 메뉴를 만들면 키보드 사용자가 막힙니다.
세 번째는 설정 화면입니다. 프로필, 보안, 알림을 Tabs로 나누면 보기 좋지만 저장 범위가 불명확하면 위험합니다. Claude Code 프롬프트에 “각 탭의 저장 책임과 미저장 상태를 표시”한다고 쓰는 편이 좋습니다.
자주 생기는 함정
첫 번째 함정은 디자인 때문에 Dialog.Title을 지우는 것입니다. 화면에 보이지 않아도 스크린 리더용 이름은 필요합니다. MDN의 dialog role 문서도 label과 focus management가 필요하다고 설명합니다.
두 번째는 focus outline을 없애는 것입니다. 디자인에 맞게 바꾸는 것은 괜찮지만, 키보드 사용자가 현재 위치를 볼 수 없게 만들면 안 됩니다.
세 번째는 asChild를 잘못 써서 button 안에 button을 만드는 것입니다. HTML이 잘못되면 브라우저와 보조 기술에서 예측하기 어려운 동작이 나옵니다.
네 번째는 여러 modal을 겹치는 흐름입니다. WAI-ARIA Modal Dialog Pattern은 열릴 때 포커스가 Dialog 내부로 이동하고 닫힌 뒤 호출한 요소로 돌아가는 흐름을 전제로 합니다. 다단 modal은 정말 필요한 경우에만 씁니다.
다섯 번째는 모바일 확인을 생략하는 것입니다. 고정 폭 Dialog, 오른쪽으로 잘리는 메뉴, 너무 긴 탭 라벨은 전환율과 사용성을 동시에 떨어뜨립니다.
공식 링크와 체크리스트
- Radix Primitives Introduction
- Radix Dialog docs
- Radix Dropdown Menu docs
- Radix Tabs docs
- WAI-ARIA Modal Dialog Pattern
- MDN dialog role
- Claude Code getting started
최소한 키보드만으로 열고 닫기, Escape 닫기, 닫은 뒤 포커스 복귀, Dropdown 방향키 이동, Tabs 방향키 이동, 모바일 폭 overflow를 확인합니다.
수익화 CTA
이 주제는 단순한 라이브러리 소개보다 실제 구현 리뷰로 이어지기 쉽습니다. 독자는 “Radix UI가 뭔가요?”보다 “우리 저장소에 넣어도 접근성과 디자인이 깨지지 않게 하려면 어떻게 하나요?”를 알고 싶어 합니다.
ClaudeCodeLab의 Claude Code 교육과 상담에서는 CLAUDE.md, 컴포넌트 규칙, 접근성 체크, review prompt, 검증 스크립트를 실제 저장소 기준으로 정리할 수 있습니다. 개인 개발자는 체크리스트와 템플릿부터 시작하고, 팀은 한 화면을 가져와 함께 review하는 방식이 현실적입니다.
실제 테스트 결과
Masa의 테스트 React 화면에서 손으로 만든 modal을 Radix Dialog로 바꾸고, 메뉴와 탭도 Radix로 옮기자 코드량은 조금 늘었지만 review 기준은 훨씬 선명해졌습니다. Claude Code에 “포커스 복귀, 읽히는 제목, 모바일 닫기 버튼, 키보드 조작을 다시 봐 달라”고 요청하니 단순한 시각 개선보다 실용적인 지적이 나왔습니다. 결론적으로 Radix UI는 접근성을 대신 생각해 주는 마법이 아니라, Claude Code가 더 안전한 기반 위에서 작업하도록 돕는 도구입니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.