Radix UI Primitives:a11y 严谨的"无样式"组件库

起因

要做一个 a11y 合规的 dropdown menu:键盘 navigation / focus trap /
ARIA roles / Escape 关闭 / 点外面关闭 / 上下方向键循环 / Home/End 键
跳第一项末项 / 输入字符直接跳到对应项 / ...

每个细节单独写都 30-50 行 JS。完全无 bug 实施一个 dropdown 要 1 天。
不抽象怎么办——5 个组件就要 1 周。

Radix UI Primitives 把这些"a11y 行为正确" 的组件做成了 React component,
不带样式,你用 Tailwind / CSS / 任何 styling 方案套外观。

npm i @radix-ui/react-dropdown-menu
npm i @radix-ui/react-dialog
npm i @radix-ui/react-tooltip
npm i @radix-ui/react-popover
# 每个组件独立 package,按需装

或者一次性装常用的:

npm i @radix-ui/themes      # 带默认主题的全套

Dropdown Menu 例子

import * as DropdownMenu from '@radix-ui/react-dropdown-menu'

function UserMenu() {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>
        <button>菜单 </button>
      </DropdownMenu.Trigger>

      <DropdownMenu.Portal>
        <DropdownMenu.Content
          className="bg-white shadow-lg rounded-md p-1 min-w-[180px]"
          sideOffset={4}
        >
          <DropdownMenu.Item
            className="px-3 py-2 rounded hover:bg-gray-100 outline-none cursor-pointer"
            onSelect={() => navigate('/profile')}
          >
            个人主页
          </DropdownMenu.Item>

          <DropdownMenu.Item
            className="px-3 py-2 rounded hover:bg-gray-100"
            onSelect={() => navigate('/settings')}
          >
            设置
          </DropdownMenu.Item>

          <DropdownMenu.Separator className="h-px bg-gray-200 my-1" />

          <DropdownMenu.Item
            className="px-3 py-2 rounded hover:bg-red-50 text-red-600"
            onSelect={logout}
          >
            退出登录
          </DropdownMenu.Item>
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  )
}

没写任何 keyboard handler / focus trap / ARIA,但 Radix 内部
全做好了:

  • ↑↓ 切换项目
  • Home / End 跳首末
  • Enter 选中
  • Escape 关闭
  • 点外面关闭
  • 输入字符跳到对应项
  • aria-expanded / aria-haspopup / aria-orientation 等 ARIA 全配
  • focus 进入 / 离开管理
  • screen reader 报"opened menu, 3 items, item 1 of 3"

业务代码只关心"我有哪些菜单项 + 点击做什么"。

Dialog 例子(合规 modal)

import * as Dialog from '@radix-ui/react-dialog'

function DeleteConfirm({ onDelete }) {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>
        <button className="btn-danger">删除</button>
      </Dialog.Trigger>

      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50" />
        <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg max-w-md">
          <Dialog.Title className="text-lg font-bold">确认删除</Dialog.Title>
          <Dialog.Description className="text-sm text-gray-500 mt-1">
            此操作不可撤销
          </Dialog.Description>

          <div className="mt-4 flex gap-2 justify-end">
            <Dialog.Close asChild>
              <button>取消</button>
            </Dialog.Close>
            <button onClick={onDelete} className="btn-danger">删除</button>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

内置:

  • focus trap(Tab 不出对话框)
  • ESC 关闭
  • 点 overlay 关闭
  • 自动 focus 第一可聚焦元素
  • 关闭后焦点回到 trigger
  • 背景 inert / aria-hidden 屏蔽
  • title / description 自动关联到对话框 aria-labelledby / aria-describedby

合规模态 5 行配置。

Tooltip / Popover

import * as Tooltip from '@radix-ui/react-tooltip'

<Tooltip.Provider delayDuration={200}>
  <Tooltip.Root>
    <Tooltip.Trigger asChild>
      <button>?</button>
    </Tooltip.Trigger>
    <Tooltip.Portal>
      <Tooltip.Content className="bg-black text-white p-2 rounded text-sm" sideOffset={4}>
        这是一段提示
        <Tooltip.Arrow className="fill-black" />
      </Tooltip.Content>
    </Tooltip.Portal>
  </Tooltip.Root>
</Tooltip.Provider>

Tooltip 之类需要全局 Provider 控制 hover delay 一致。

Accordion

import * as Accordion from '@radix-ui/react-accordion'

<Accordion.Root type="single" collapsible className="w-[400px]">
  <Accordion.Item value="item-1">
    <Accordion.Header>
      <Accordion.Trigger className="w-full text-left">
        什么是 React Hooks
      </Accordion.Trigger>
    </Accordion.Header>
    <Accordion.Content>
      Hooks  React 16.8 引入的特性...
    </Accordion.Content>
  </Accordion.Item>

  <Accordion.Item value="item-2">
    ...
  </Accordion.Item>
</Accordion.Root>

type='single' 同时只展开一个;type='multiple' 多个。
键盘 Tab / Space / Enter 展开收起,箭头切项。

Switch / Checkbox / RadioGroup

import * as Switch from '@radix-ui/react-switch'

<Switch.Root
  className="w-11 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-blue-500"
  checked={isDark}
  onCheckedChange={setIsDark}
>
  <Switch.Thumb className="block w-5 h-5 bg-white rounded-full shadow translate-x-0.5 data-[state=checked]:translate-x-5 transition" />
</Switch.Root>

完整 keyboard support + ARIA + 适合 screen reader 的 label 关联。

asChild 模式

很多 Radix 组件支持 asChild

<DropdownMenu.Trigger asChild>
  <button>Click</button>   {/* 把 trigger 的行为应用到这个 button */}
</DropdownMenu.Trigger>

// vs 默认(包一层 button)
<DropdownMenu.Trigger>Click</DropdownMenu.Trigger>

asChild 让你用自己的元素(甚至自定义 styled component)作为 trigger,
不强制额外 DOM。React Aria / Headless UI 也类似。

跟 shadcn/ui 关系

shadcn/ui = "Radix Primitives + Tailwind 样式 + 复制粘贴" 的预设。
直接:

npx shadcn-ui@latest add dropdown-menu dialog tooltip

代码进你的 components/ui/,底层就是 Radix。
省了"自己包样式" 这一步。

跟 Headless UI / React Aria 对比

Radix UI Headless UI React Aria (Adobe)
风格 较细粒度 primitive 简洁 极完整(也最重)
组件数 ~30 10+ ~50
Tailwind 友好 ✅(同公司) 中性
学习曲线 中-高
a11y 严谨度 极高
bundle 中(按需) 中-大

shadcn 默认用 Radix;个人偏好用 Headless UI(Tailwind 同公司)也很
好。

实际效果

我们一个 React app 把所有 dialog / dropdown / popover / tooltip 改 Radix:

  • 之前自己实现 + 半成品 → bug 多 + 不一致
  • 改 Radix 后 a11y 自动达标(axe-core lint 0 violation)
  • bundle 增加 ~30KB(5-6 个 primitive)
  • 团队 review focus 从"键盘怎么操作" → 只看业务逻辑

几个建议

  1. 不要从 Radix 自己包 100 个 wrapper:用 shadcn 把 Radix + Tailwind
    集成代码丢进自己 repo 直接改更灵活
  2. 状态管理交给 Radix:除非需要受控 (controlled mode),让 Radix
    内部管 open state
  3. portal 默认开:菜单 / dialog 进 portal 避免 overflow / z-index
    父元素影响
  4. className 接受:所有 primitive 都接 className + style,
    尽情用 Tailwind / CSS Modules

踩过的坑

  1. DropdownMenu 不能 nested:嵌套菜单要用 DropdownMenu.Sub
    不是再嵌一个 Root。

  2. Dialog 内嵌 Tooltip 不显示:z-index / portal 父子关系问题。
    Tooltip 也 portal 出去。

  3. asChild + Link:React Router <Link> 已经是 <a>
    asChild 时不要重复包 <a>

  4. server side rendering:Radix 用 useId / 假 portal,SSR 时
    小心 hydration mismatch。包 dynamic import / suppress hydration warning。

  5. bundle 一个 component 拉一堆 dep:每个 Radix component 拉
    @radix-ui/react-primitive 等 shared util。多组件用同样 base
    tree-shake 后实际不重复,但单独看 size 比想象大。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。