用 Claude Code 和 Radix UI 构建可访问 React UI
用 Claude Code 和 Radix UI 实现 Dialog、Dropdown、Tabs,包含安装、代码、样式、坑点和检查清单。
在 React 中自己写弹窗、下拉菜单和标签页,看起来并不难。真正容易出问题的是键盘操作、焦点回到哪里、屏幕阅读器能不能读出标题、Escape 是否能关闭,以及移动端是否溢出。只用鼠标测试时一切正常,发布后才发现键盘用户无法完成操作,这是很多 UI 项目的常见问题。
Radix UI 适合解决这类问题。它不是带完整视觉风格的组件库,而是一组无样式的 UI primitives,也就是“行为地基”。你仍然自己决定颜色、间距、圆角和动效,但 Dialog、Dropdown Menu、Tabs 等复杂交互不必从零实现。
Claude Code 也更适合在这种边界清楚的任务里工作。不要让它凭空写一个 modal,而是明确要求“使用 Radix UI,保留可访问性语义,并接入现有样式”。如果你想使用更高层的 Radix 封装,可以继续读 Claude Code shadcn/ui 指南。如果想系统检查无障碍质量,可以配合 Claude Code 无障碍实现指南。
为什么 Radix UI 适合交给 Claude Code
Radix 官方把 Primitives 描述为重视 accessibility、customization 和 developer experience 的低层 UI 组件库。换句话说,Radix 不替你设计品牌视觉,而是提供角色、焦点管理、键盘交互和组件结构。
Dialog 是典型例子。Radix Dialog 支持 modal 和 non-modal,modal 模式会把焦点限制在弹窗内部,支持 Escape 关闭,并通过 Title 和 Description 帮助屏幕阅读器理解内容。Tabs 遵循 WAI-ARIA tabs pattern,处理方向键、Home、End。Dropdown Menu 提供 Label、Separator、RadioItem 等结构,避免你手写整套菜单焦点逻辑。
Claude Code 的工作循环是先收集上下文,再修改代码,再验证结果。如果让它从 div 开始写复杂交互,生成代码会变长,review 成本也会升高。指定 Radix UI 后,Claude Code 可以专注在项目相关部分:状态、API、样式类名、埋点、测试和检查清单。
flowchart LR
A["把 UI 需求交给 Claude Code"] --> B["用 Radix UI 负责交互行为"]
B --> C["连接 React 状态和业务逻辑"]
C --> D["套用项目 CSS 或设计 token"]
D --> E["检查键盘、读屏和移动端"]
安装命令
Radix 现在也推荐使用统一的 radix-ui 包,但许多 React 项目仍然使用独立包。本文用独立包,因为在 package.json 中更容易看出项目实际用了哪些 primitives。
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
团队环境不要只把它当成一个全局 npm 包。需要提前确定升级方式、权限边界、CI 中是否允许运行,以及谁负责 review 生成的 UI 差异。
给 Claude Code 的提示词
好的提示词要描述交互约束,而不是只描述外观。
claude "在现有 React + TypeScript 页面中加入确认 Dialog、用户 Dropdown Menu 和设置 Tabs。
要求:
- 使用 @radix-ui/react-dialog、@radix-ui/react-dropdown-menu、@radix-ui/react-tabs
- Dialog 必须包含 Dialog.Title 和 Dialog.Description
- 只有图标的关闭按钮必须有 aria-label
- 不要移除可见 focus 样式,使用 :focus-visible
- 尽量复用现有 design token
- 完成后列出键盘、移动端、typecheck 和无障碍检查项"
生成后不要只看截图。要看 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 不带样式,所以你可以使用 Tailwind、CSS Modules、普通 CSS 或 design token。无论用哪种方式,都不要隐藏 focus 样式,也不要把 Dialog 写成固定宽度。
.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 状态、错误信息、测试 |
| 账号菜单 | 键盘移动、分隔线、单选项更容易维护 | 用户信息、退出登录、计费入口、埋点 |
| 设置页面 Tabs | tablist、tab、tabpanel 的关系更稳定 | 表单拆分、保存逻辑、URL 同步、未保存提示 |
第一个场景是 SaaS 后台。删除、计费、权限变更都属于高风险操作,最重要的不是动画,而是让用户清楚知道后果。Radix Dialog 给出可靠的行为基础,Claude Code 再接入业务 API。
第二个场景是课程、媒体或会员网站。账号菜单经常包含资料、订单、下载、学习进度和退出登录。自己写 click-only menu 很容易忽略键盘用户,Radix Dropdown Menu 能减少这类问题。
第三个场景是设置页。Tabs 能把 profile、security、notifications 分开,但保存范围必须清楚。可以让 Claude Code 同时添加 dirty state、保存按钮位置和错误提示。
常见坑
第一个坑是为了视觉简洁删掉 Dialog.Title。如果页面上不想显示标题,也要保留给屏幕阅读器使用的标题。MDN 的 dialog role 文档也强调,dialog 需要正确 label 和 focus management。
第二个坑是把 focus outline 全部删掉。可以替换成符合设计系统的 :focus-visible,但不能让键盘用户看不到当前位置。
第三个坑是错误使用 asChild。如果生成结果里出现 button 套 button,HTML 就不合法,浏览器和辅助技术的行为都可能不稳定。
第四个坑是做太多层 modal。WAI-ARIA Modal Dialog Pattern 说明了打开时焦点进入弹窗、关闭后回到触发元素的基本预期。多层弹窗会让这个流程变得难以理解。
第五个坑是只看桌面。移动端固定宽度 Dialog、靠右菜单溢出、Tabs 文案换行后难点按,都会影响真实转化。
官方链接和检查清单
- 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
发布前至少检查这些点:只用键盘能打开和关闭 Dialog;Escape 可关闭;关闭后焦点回到触发按钮;Dropdown 能用方向键移动;Tabs 能用方向键切换;手机宽度不溢出。
变现 CTA
这个主题适合连接到实际项目服务,因为读者通常不是只想了解库,而是想把现有 UI 改到可上线、可 review。ClaudeCodeLab 可以通过 Claude Code 培训与咨询 帮团队整理 CLAUDE.md、组件规则、无障碍检查、review prompt 和验证脚本。个人开发者可以先用模板和 checklist,团队则更适合带真实仓库做一次 UI review。
实际测试结果
Masa 在一个 React 测试页面中把手写 modal 替换为 Radix Dialog,并把用户菜单和设置 Tabs 也迁移到 Radix。代码量略有增加,但 review 变得更明确:焦点是否回到触发按钮、标题是否能被读出、手机宽度是否能点击关闭按钮。让 Claude Code 针对这些点复查,比只要求“让 UI 更好看”有用得多。结论是,Radix UI 不是让你跳过无障碍思考的捷径,而是给 Claude Code 一个更安全的行为地基。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。