Radix UI 入门
本文以 React 18+ / TypeScript 5+ / Tailwind CSS 4+ / pnpm 为基线,展示从「零样式 Radix Primitives」到「带样式 Radix Themes」的完整入门路径——你将学会两条产品线分别的安装、使用、与 shadcn/ui 的关系。
Radix UI 是 React 生态最特殊的 UI 库 —— 它不是「给你一套带样式的组件」,而是给你「带行为 + a11y + 键盘 + 焦点管理」的盒子。一定先理解 index 页 提到的 「Primitives vs Themes」 二分。
0. 环境与前置要求
- Node.js ≥ 20(推荐 22 LTS)
- 包管理器
pnpm(推荐)/npm/yarn任意 - React ≥ 18(强烈推荐 19,原生
useIdSSR 友好) - TypeScript ≥ 5.0
- 框架:Vite 7+ / Next.js 15+ App Router / Remix / TanStack Router
Radix Primitives 完全不依赖任何 CSS 工具,但 99% 的实际项目会搭配 Tailwind CSS 4+。本文示例默认假设你已经安装并配置好 Tailwind。
1. 三条主流路径概览
Radix UI 在实际项目中通常按以下三条路径之一引入,选哪条取决于你的设计自由度需求:
路径 A:直接用 Radix Primitives(最大自由度)
适合 设计驱动 + 完全自定义视觉 场景。你自己用 Tailwind / CSS 写所有样式。
# 方式 1:独立包(按需安装)
pnpm add @radix-ui/react-dialog @radix-ui/react-dropdown-menu
# 方式 2:聚合包(一次拿全)
pnpm add radix-ui路径 B:用 shadcn/ui(推荐 99% 场景)
shadcn/ui = Radix Primitives + Tailwind + 拷贝代码到本地。截至 2026 年 90k+ Star。不是 npm 包,而是 CLI 工具拷贝代码到你的项目。
# shadcn/ui 初始化(项目根目录)
pnpm dlx shadcn@latest init
# 拷贝具体组件代码到 src/components/ui
pnpm dlx shadcn@latest add dialog dropdown-menu buttonshadcn 拷贝来的 src/components/ui/dialog.tsx 底层就是 Radix Primitives——所以学会 Radix Primitives = 半个 shadcn 已经会了。
路径 C:用 Radix Themes(最快上手)
适合 追求开箱即用 + 默认设计已经够好 + 不需要 Tailwind 的项目。
pnpm add @radix-ui/themes本入门指南聚焦路径 A(Primitives + Tailwind)和路径 C(Themes),路径 B 见 shadcn/ui 官方文档。
2. 路径 A:Radix Primitives + Tailwind
2.1 安装第一个 Primitive(Dialog)
# 创建 Vite + React + TS 项目(如果还没有)
pnpm create vite@latest my-app -- --template react-ts
cd my-app
pnpm install
# 安装 Tailwind CSS 4
pnpm add tailwindcss @tailwindcss/vite
# 安装 Radix Dialog
pnpm add @radix-ui/react-dialogvite.config.ts:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});src/index.css:
@import "tailwindcss";2.2 第一个 Dialog 组件
src/components/MyDialog.tsx:
import * as Dialog from "@radix-ui/react-dialog";
/**
* 自定义对话框组件
* - Compound Component 模式:Root + Trigger + Portal + Overlay + Content + Title + Description + Close
* - 默认 Portal 到 body,避免 z-index / overflow 问题
* - 默认 modal,按 Esc 关闭、焦点自动陷阱
*/
export function MyDialog() {
return (
<Dialog.Root>
{/* 触发器 —— 任何 React 节点都可以 */}
<Dialog.Trigger asChild>
<button className="rounded-lg bg-indigo-600 px-4 py-2 text-white hover:bg-indigo-700">
打开对话框
</button>
</Dialog.Trigger>
{/* Portal —— 默认渲染到 body 末端 */}
<Dialog.Portal>
{/* 遮罩层 */}
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
{/* 内容容器 */}
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-white p-6 shadow-2xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95">
{/* a11y 必须:Title + Description(可隐藏但不可省) */}
<Dialog.Title className="text-lg font-semibold">
欢迎使用 Radix Dialog
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-gray-600">
这是一个完全无样式的 Dialog,所有视觉由你的 Tailwind 类决定。
</Dialog.Description>
<div className="mt-6 flex justify-end gap-2">
<Dialog.Close asChild>
<button className="rounded-lg border px-3 py-1.5 text-sm">
取消
</button>
</Dialog.Close>
<Dialog.Close asChild>
<button className="rounded-lg bg-indigo-600 px-3 py-1.5 text-sm text-white">
确认
</button>
</Dialog.Close>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}src/App.tsx:
import { MyDialog } from "./components/MyDialog";
function App() {
return (
<div className="flex min-h-screen items-center justify-center">
<MyDialog />
</div>
);
}
export default App;pnpm dev打开 http://localhost:5173,点击按钮 —— 你将看到一个 Tailwind 风格的 Dialog 弹出,Esc 关闭、焦点锁定、a11y 完美——但所有视觉100% 由你的 Tailwind 类决定。
2.3 关键概念:Compound Component
Radix 的所有 Primitives 都是复合组件——一个组件拆成多个子组件、每个子组件独立暴露给你:
// Dialog 的完整 anatomy
<Dialog.Root> {/* 状态容器 */}
<Dialog.Trigger /> {/* 触发器(任意元素) */}
<Dialog.Portal> {/* Portal 到 body */}
<Dialog.Overlay /> {/* 遮罩层 */}
<Dialog.Content> {/* 内容容器 */}
<Dialog.Title /> {/* a11y 标题 */}
<Dialog.Description /> {/* a11y 描述 */}
<Dialog.Close /> {/* 关闭按钮 */}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>为什么这样设计:
- 结构清晰——anatomy 一眼看穿
- 可拆分——你可以把 Trigger 放在任意位置
- 可单独样式——每个 sub-component 有自己的 className
- 细粒度控制——比如自定义 Portal 容器、跳过 Overlay 等
2.4 关键概念:asChild Slot 模式
Radix 所有 Trigger / Close / Item 等都支持 asChild prop——把功能注入到子元素而非额外渲染一层 DOM:
// 不用 asChild —— Radix 会渲染默认的 <button>
<Dialog.Trigger>打开</Dialog.Trigger>
// 实际 DOM: <button>打开</button>
// 用 asChild —— Radix 把功能合并到你的 <a> 上
<Dialog.Trigger asChild>
<a href="#dialog">打开</a>
</Dialog.Trigger>
// 实际 DOM: <a href="#dialog" data-state="closed" ...>打开</a>与 Next.js Link 组合:
import Link from "next/link";
import * as Dialog from "@radix-ui/react-dialog";
<Dialog.Trigger asChild>
<Link href="/products" className="text-blue-500">
商品列表
</Link>
</Dialog.Trigger>;
asChild要求:子元素必须接收并展开所有 props、必须使用React.forwardRef转发 ref。Next.js Link / React Router Link 默认满足。
2.5 关键概念:data-state 状态属性
Radix 在每个有状态的 DOM 元素上挂 data-state 属性:
<button data-state="open">打开</button>
<div data-state="open">遮罩</div>
<div data-state="open" data-side="top" data-align="center">内容</div>用 Tailwind 选择器响应状态:
<Dialog.Trigger
className="
bg-indigo-600
data-[state=open]:bg-indigo-800
data-[state=closed]:bg-indigo-600
"
>
按钮
</Dialog.Trigger>用纯 CSS 写状态样式:
.MyTrigger[data-state="open"] {
background-color: #4338ca;
}2.6 关键概念:CSS 变量驱动动画
Radix 把组件的几何信息(高度、宽度、变换原点)暴露为 CSS 变量——纯 CSS 即可写动画,不需要 JS 测量:
/* Accordion 展开高度动画 */
.AccordionContent[data-state="open"] {
animation: slideDown 200ms ease-out;
}
.AccordionContent[data-state="closed"] {
animation: slideUp 200ms ease-out;
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes slideUp {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}完整 CSS 变量列表见 指南 「CSS 变量动画」章节。
3. 第二个示例:Dropdown Menu(含 SubMenu)
pnpm add @radix-ui/react-dropdown-menusrc/components/MyDropdown.tsx:
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
/**
* 下拉菜单组件
* - 含 SubMenu 二级菜单
* - 含 Checkbox Item
* - 自动键盘导航:Arrow / Enter / Esc / Type to find
*/
export function MyDropdown() {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="rounded-lg border px-4 py-2 hover:bg-gray-50">
选项
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="z-50 min-w-44 rounded-lg border bg-white p-1 shadow-lg"
sideOffset={4}
align="start"
>
<DropdownMenu.Item className="cursor-pointer rounded px-3 py-1.5 text-sm outline-none data-[highlighted]:bg-indigo-50 data-[highlighted]:text-indigo-900">
新建
</DropdownMenu.Item>
<DropdownMenu.Item className="cursor-pointer rounded px-3 py-1.5 text-sm outline-none data-[highlighted]:bg-indigo-50 data-[highlighted]:text-indigo-900">
打开
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-gray-200" />
{/* 二级菜单 */}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger className="flex cursor-pointer items-center justify-between rounded px-3 py-1.5 text-sm outline-none data-[highlighted]:bg-indigo-50">
更多
<span className="ml-2 text-gray-400">▶</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent
className="z-50 min-w-44 rounded-lg border bg-white p-1 shadow-lg"
sideOffset={4}
>
<DropdownMenu.Item className="rounded px-3 py-1.5 text-sm data-[highlighted]:bg-indigo-50">
导出 PDF
</DropdownMenu.Item>
<DropdownMenu.Item className="rounded px-3 py-1.5 text-sm data-[highlighted]:bg-indigo-50">
导出 CSV
</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
<DropdownMenu.Separator className="my-1 h-px bg-gray-200" />
<DropdownMenu.Item className="cursor-pointer rounded px-3 py-1.5 text-sm text-red-600 data-[highlighted]:bg-red-50">
删除
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}注意
data-[highlighted]:bg-indigo-50—— 键盘导航高亮态通过data-highlighted属性暴露,Tailwind 选择器可直接响应。
4. 第三个示例:Popover
pnpm add @radix-ui/react-popoverimport * as Popover from "@radix-ui/react-popover";
export function MyPopover() {
return (
<Popover.Root>
<Popover.Trigger asChild>
<button className="rounded-lg border px-3 py-1.5">详情</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="z-50 w-64 rounded-lg border bg-white p-4 shadow-lg outline-none"
sideOffset={8}
align="end"
>
<h4 className="font-semibold">产品规格</h4>
<p className="mt-1 text-sm text-gray-600">
尺寸 12.5cm × 8.0cm,重量 250g
</p>
<Popover.Arrow className="fill-white" />
<Popover.Close asChild>
<button className="absolute right-2 top-2 text-gray-400 hover:text-gray-600">
×
</button>
</Popover.Close>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
Popover.Arrow是 Radix 自动绘制的指向 Trigger 的小三角,自动跟随 collision detection 调整位置。
5. 受控 vs 非受控
所有 Primitives 都支持两种模式:
// 非受控(Uncontrolled)—— Radix 自己管理 open 状态
<Dialog.Root defaultOpen={false}>
...
</Dialog.Root>
// 受控(Controlled)—— 你自己管理 open 状态
function MyDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger>打开</Dialog.Trigger>
...
</Dialog.Root>
);
}受控的好处:可以通过外部状态(如 URL / global state / 父组件)控制对话框,适合需要程序化打开/关闭的场景。
6. Portal 与 SSR
6.1 默认 Portal 行为
Dialog / Popover / Dropdown / Tooltip / Select 等 overlay 类组件默认 Portal 到 <body> 末端:
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>...</Dialog.Content>
</Dialog.Portal>这彻底解决了:
z-index层叠上下文- 父级
overflow: hidden裁剪 - 父级
transform/filter锚定丢失
6.2 自定义 Portal 容器
const containerRef = useRef<HTMLDivElement>(null);
return (
<div ref={containerRef}>
<Dialog.Root>
<Dialog.Portal container={containerRef.current}>
...
</Dialog.Portal>
</Dialog.Root>
</div>
);适合模态框需要限制在特定容器内(如 iframe 内部)。
6.3 Next.js App Router SSR
// app/page.tsx —— Server Component
import { MyDialog } from "@/components/MyDialog";
export default function Page() {
return <MyDialog />;
}// components/MyDialog.tsx —— Client Component
"use client"; // 必须,因为用到 useState / useEffect
import * as Dialog from "@radix-ui/react-dialog";
// ...Radix React 18+ 自带
useId—— 无 hydration warning、SSR 完美。只需在 Client Component 用 Radix Primitives。
7. 路径 C:Radix Themes 完整安装
如果你不想自己写样式 + 不需要 Tailwind 自由度,那 Radix Themes 是更快的选择。
7.1 安装
pnpm add @radix-ui/themes7.2 导入 CSS + 包根 Theme
src/main.tsx:
import React from "react";
import ReactDOM from "react-dom/client";
import "@radix-ui/themes/styles.css"; // 必须导入
import { Theme } from "@radix-ui/themes";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Theme accentColor="indigo" grayColor="slate" radius="medium" scaling="100%">
<App />
</Theme>
</React.StrictMode>,
);7.3 第一个 Themes 组件
src/App.tsx:
import { Button, TextField, Card, Flex, Heading, Text } from "@radix-ui/themes";
export default function App() {
return (
<Flex direction="column" gap="4" p="6" maxWidth="500px">
<Heading>用户登录</Heading>
<Card>
<Flex direction="column" gap="3">
<Text size="2" weight="bold">
邮箱
</Text>
<TextField.Root placeholder="your@email.com" size="3" />
<Text size="2" weight="bold" mt="3">
密码
</Text>
<TextField.Root type="password" placeholder="••••••••" size="3" />
<Button size="3" mt="4">
登录
</Button>
<Button size="3" variant="soft">
忘记密码?
</Button>
</Flex>
</Card>
</Flex>
);
}特点:
- 不需要写一行 CSS
- 自动响应
accentColor="indigo"主题色 - 6 个 variant(
solid/soft/outline/ghost/surface/classic) - 4 个 size(
"1"/"2"/"3"/"4") - 响应式 props 对象语法(见下节)
7.4 Theme 完整 Props
<Theme
accentColor="indigo" // 主色(16 选 1:indigo / blue / red / purple / green / mint / ...)
grayColor="slate" // 灰色(6 选 1:gray / mauve / slate / sage / olive / sand)
panelBackground="solid" // 面板背景:solid / translucent
radius="medium" // 圆角:none / small / medium / large / full
scaling="100%" // 整体缩放:90% / 95% / 100% / 105% / 110%
appearance="light" // 主题模式:light / dark / inherit
>
{children}
</Theme>7.5 响应式 Prop 对象
<Flex
direction={{ initial: "column", md: "row" }}
gap={{ initial: "2", md: "4" }}
p={{ initial: "3", md: "6" }}
>
<Button size={{ initial: "2", md: "3" }}>响应式按钮</Button>
</Flex>断点:initial / xs / sm / md / lg / xl(与 Tailwind 接近但前缀不同)。
7.6 暗色模式
// 方式 1:appearance prop 硬编码
<Theme appearance="dark">...</Theme>
// 方式 2:跟随系统
<Theme appearance="inherit">...</Theme>
// 方式 3:next-themes 集成(推荐)
import { ThemeProvider } from "next-themes";
<ThemeProvider attribute="class" defaultTheme="system">
<Theme>{children}</Theme>
</ThemeProvider>;8. 与 shadcn/ui 的关系
shadcn/ui = Radix Primitives + Tailwind + CLI 拷贝代码:
pnpm dlx shadcn@latest init # 初始化(选 Tailwind / 路径等)
pnpm dlx shadcn@latest add dialog # 拷贝 dialog 组件到 src/components/ui拷贝来的 src/components/ui/dialog.tsx 长这样(简化):
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out",
className,
)}
{...props}
/>
));
// ...其余 sub-component
export { Dialog, DialogTrigger, DialogPortal, DialogOverlay /* ... */ };你可以直接修改这个 dialog.tsx 文件——这就是 shadcn 「拷贝代码、不依赖 npm」的哲学。所以会 Radix Primitives = 半个 shadcn 已经会了。
9. 完整 Next.js App Router 集成
9.1 安装
pnpm create next-app@latest my-app --typescript --tailwind --app
cd my-app
pnpm add @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-popover9.2 layout.tsx(Server Component)
// app/layout.tsx
import "./globals.css";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body>{children}</body>
</html>
);
}9.3 Client Component 使用 Radix
// app/page.tsx
import { MyDialog } from "@/components/MyDialog";
export default function Home() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold">首页</h1>
<MyDialog />
</main>
);
}// components/MyDialog.tsx
"use client"; // 必须,因为用到 useState
import * as Dialog from "@radix-ui/react-dialog";
import { useState } from "react";
export function MyDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
{/* ... */}
</Dialog.Root>
);
}9.4 Radix Themes + Next.js + next-themes
pnpm add @radix-ui/themes next-themes// app/layout.tsx
import "@radix-ui/themes/styles.css";
import { Theme } from "@radix-ui/themes";
import { ThemeProvider } from "next-themes";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<Theme accentColor="indigo" grayColor="slate" radius="medium">
{children}
</Theme>
</ThemeProvider>
</body>
</html>
);
}10. 完整 Vite 集成
vite.config.ts(前面已写):
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});src/main.tsx:
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);如果用 Radix Themes,多加一行 import "@radix-ui/themes/styles.css":
import React from "react";
import ReactDOM from "react-dom/client";
import "@radix-ui/themes/styles.css";
import "./index.css";
import { Theme } from "@radix-ui/themes";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<Theme accentColor="indigo">
<App />
</Theme>,
);11. 第一个表单(Primitives + Tailwind)
src/components/LoginForm.tsx:
import * as Form from "@radix-ui/react-form";
import { useState } from "react";
/**
* 登录表单
* - 用 @radix-ui/react-form Primitive
* - HTML5 原生校验 + Radix Validation Message
*/
export function LoginForm() {
const [submitted, setSubmitted] = useState(false);
return (
<Form.Root
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget));
console.log("提交:", data);
setSubmitted(true);
}}
>
<Form.Field name="email" className="block">
<Form.Label className="block text-sm font-medium">邮箱</Form.Label>
<Form.Control asChild>
<input
type="email"
required
className="mt-1 block w-full rounded border px-3 py-2 outline-none focus:border-indigo-500"
/>
</Form.Control>
<Form.Message
match="valueMissing"
className="mt-1 block text-xs text-red-500"
>
邮箱不能为空
</Form.Message>
<Form.Message
match="typeMismatch"
className="mt-1 block text-xs text-red-500"
>
请输入有效邮箱
</Form.Message>
</Form.Field>
<Form.Field name="password">
<Form.Label className="block text-sm font-medium">密码</Form.Label>
<Form.Control asChild>
<input
type="password"
required
minLength={8}
className="mt-1 block w-full rounded border px-3 py-2 outline-none focus:border-indigo-500"
/>
</Form.Control>
<Form.Message
match="valueMissing"
className="mt-1 block text-xs text-red-500"
>
密码不能为空
</Form.Message>
<Form.Message
match="tooShort"
className="mt-1 block text-xs text-red-500"
>
密码至少 8 位
</Form.Message>
</Form.Field>
<Form.Submit asChild>
<button className="w-full rounded bg-indigo-600 py-2 text-white">
登录
</button>
</Form.Submit>
{submitted && (
<p className="text-sm text-green-600">提交成功(控制台查看数据)</p>
)}
</Form.Root>
);
}
Form.Message match="..."接受 HTML5 ValidityState key(valueMissing/typeMismatch/tooShort/tooLong/patternMismatch/rangeUnderflow/rangeOverflow等),校验完全用浏览器原生。
12. 调试技巧
12.1 检查 data-state 属性
打开 DevTools,找到 Radix 组件根 DOM 元素 —— 你会看到:
<button data-state="closed" aria-expanded="false">打开</button>
<!-- 点击后 -->
<button data-state="open" aria-expanded="true">打开</button>用 data-[state=open]: Tailwind 选择器响应状态变化。
12.2 检查 Radix CSS 变量
打开 DevTools,找到 Content 元素 —— 你会看到:
:root {
--radix-dialog-content-transform-origin: var(--radix-popper-transform-origin);
--radix-popper-available-width: 1200px;
--radix-popper-available-height: 800px;
--radix-popper-trigger-width: 80px;
--radix-popper-trigger-height: 36px;
}这些 CSS 变量可以在 className 里直接用(如 style={{ width: "var(--radix-popover-trigger-width)" }})。
12.3 检查焦点陷阱
打开 Dialog 后按 Tab 键 —— 焦点应该只在 Dialog 内部循环。如果焦点跳到外部,说明 Portal 配置有问题。
12.4 Strict Mode 双调用警告
React 18 Strict Mode 会故意双调用所有 effect ——某些 Radix 组件首次启动时会出现警告,升级到最新 Radix 版本通常已修复。
13. 常见问题排查
13.1 Module not found: @radix-ui/react-dialog
# 确认依赖已安装
cat package.json | grep radix
# 重新安装
pnpm install13.2 Hydration warning(Next.js)
确认 使用 Radix 的组件加了 "use client",并且 useState 不在 Server Component 顶层。
13.3 asChild 报错:Children.only expected single React element
Dialog.Trigger asChild 等只能有一个 React 子元素,不能多个:
// 错误
<Dialog.Trigger asChild>
<button>打开</button>
<span>说明</span>
</Dialog.Trigger>
// 正确
<Dialog.Trigger asChild>
<button>
打开
<span>说明</span>
</button>
</Dialog.Trigger>13.4 自定义 asChild 子组件不工作
子组件必须 forwardRef 并展开 props:
// 错误
const MyButton = ({ children }: { children: React.ReactNode }) => (
<button>{children}</button>
);
// 正确
const MyButton = React.forwardRef<
HTMLButtonElement,
React.ComponentPropsWithoutRef<"button">
>(({ children, ...props }, ref) => (
<button ref={ref} {...props}>
{children}
</button>
));13.5 z-index 仍然冲突
Radix Portal 默认渲染到 <body> 末端 —— 如果你的样式系统有全局 z-index 规则,给 Overlay 和 Content 显式设置 z-index:
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 ..." />13.6 Radix Themes 与 Tailwind 样式冲突
官方不推荐 Themes + Tailwind 混用——Tailwind 会穿透到 Themes 组件内部 导致样式冲突。
如果非要混用,建议:
- Themes 组件用 Themes props(
size/variant/color)控制 - Tailwind 只用在布局层(Box / Flex / Grid 等容器)
- 不用
className覆盖 Themes 组件内部样式
14. 完成入门后
掌握上述内容后,可继续阅读:
- 指南:Radix Primitives 全部 30+ 组件 / Themes 全部 70+ 组件 /
data-state+ CSS 变量动画 / Tailwind 集成最佳实践 /<Theme>完整配置 / Layout 系统 / Radix Colors 12 阶 / 常见踩坑 - 参考:30+ Primitives API 速查 / 70+ Themes API 速查 / 键盘快捷键全表 / CSS 变量全表 / TypeScript 类型
- shadcn/ui 官方文档:Radix 上层最流行的实践
- Radix Colors:业界事实标准的 12 阶语义色板
- Radix Icons:300+ 风格统一的图标(虽然推荐 Lucide)