Base UI 与 React Aria 深度对比:底层机制、现代 API 与 HeroUI 样式复用
AI 参与说明(Agent:Codex):本文通过 Context7、官方文档、npm 发布元数据及固定版本源码整理,比较 Base UI 与 React Aria 的交互、状态、组合 API 和工程边界,并核查 HeroUI 样式复用。资料整理日期为 2026-10-15;选型结论属于工程判断,具体运行验证见示例说明。运行记录:模型
gpt-6.1-sol,reasoning effortultra,执行入口 Codex Desktop,提供方openai,CLI 版本0.162.0-alpha.17.2(不代表桌面 App 版本)。
如果要从 primitives 建立自己的设计系统,Base UI 的部件边界与组合协议很值得选;如果产品需要复杂选择、数据集合、日期和多语言交互,React Aria 通常能让这些能力共享更多基础设施。两者都有现代的组件 API,不能按“Base UI 新、React Aria 旧”来判断。
HeroUI 的样式可以用于自建 Base UI 组件。准确的实现边界是:使用 @heroui/styles 提供视觉,由 Base UI 负责交互、ARIA、焦点和表单。Button 接起来很直接;Checkbox、Select、Dialog 等还涉及 DOM、状态选择器、浮层与动画,需要逐类适配。HeroUI 的 render 则是另一件事:它保留 HeroUI 的行为,只替换渲染节点。
比较范围与术语
本文的 Base UI 指 @base-ui/react,不是 Uber 的 Base Web,也不是旧 @mui/base。React Aria 则拆成两层比较:日常建立设计系统主要比较 React Aria Components,需要完全接管 DOM 或行为时再看 React Aria Hooks + React Stately。只拿 Base UI 的成品 primitive 与 React Aria 的底层 Hooks 比代码行数,会误判使用成本。Base UI About、React Aria Hooks
| 英文术语 | 中文名称 | 简要解释 |
|---|---|---|
| Headless | 无预设视觉样式 | 提供状态和交互规则,由应用决定外观;仍可能输出 DOM 和布局所需样式 |
| Primitive | 基础构件 | Button、Dialog Trigger 等可继续组合的组件单元 |
| React Aria Components | 保留原名 | 已经组装好交互、状态和语义关系的无样式 React 组件,本文简称 RAC |
| React Stately | 保留原名 | 管理选择、集合、日期等状态的配套 Hooks |
| Collection | 集合 | 带稳定身份与顺序的项目模型,供导航、选择、虚拟化等行为使用 |
| Render Prop | 渲染属性 | 接收状态或 DOM props,交由调用方生成内容或节点的函数接口 |
| Slot | 插槽 | 在组合组件中标识某个部件的用途,例如增加按钮与减少按钮 |
| Controlled Component | 受控组件 | 最终状态由父组件传入,组件通过回调请求变更 |
| Focus Management | 焦点管理 | 处理初始焦点、键盘移动、弹层焦点约束和关闭后的焦点恢复 |
| Hydration | 水合 | 浏览器将 React 交互接到服务端已输出的 HTML 上 |
核查版本如下。文档网站会持续更新,源码分析固定在对应发布提交;这些版本是研究快照,不表示所有项目都应立即升级。
| 对象 | 本文版本 | 用途 |
|---|---|---|
@base-ui/react | 1.9.0 | Base UI API 与源码,发布于 2026-10-09 |
react-aria-components | 1.22.1 | RAC 当前组件 API,发布于 2026-10-09 |
react-aria | 3.53.1 | 交互与无障碍 Hooks |
react-stately | 3.51.0 | 状态 Hooks |
@heroui/styles、@heroui/react | 3.2.6 | 样式独立复用与 HeroUI 组件对照 |
版本来源:Base UI npm、RAC npm、React Aria npm、React Stately npm、HeroUI styles npm。
专业开发者应比较什么
| 维度 | Base UI | React Aria / RAC | 对工程的影响 |
|---|---|---|---|
| 主要组织方式 | 每种模式提供显式 parts,如 Dialog.Trigger、Dialog.Popup | 通用组件通过 Context 和 Slot 参与复合模式 | 前者局部结构容易发现,后者跨模式复用更突出 |
| DOM 组合 | render 接受元素或函数;提供 useRender | 当前也支持 render 函数;可继续下沉到 Hooks | 两者都允许控制节点,不能沿用 RAC 无 render 的旧结论 |
| 常用命名 | disabled、checked、onCheckedChange | isDisabled、isSelected;Select 当前为 value/onChange | 按组件核查,不能机械替换属性名 |
| 激活事件 | Button 常用 onClick,内部补齐所需行为 | onPress 抽象鼠标、触摸、键盘和辅助技术激活 | RAC 显式公开统一的交互语义 |
| 变更解释 | 多个组件回调提供 reason、原始事件、取消方法 | 回调随模式区分,Hooks 提供具体交互控制 | Base UI 很适合按关闭原因定制局部策略 |
| Collection | Combobox 有 items、createItems,也有 DOM 注册机制 | 多类组件共享 Collection 与 Selection 基础设施 | 数据应用要比较抽象覆盖范围,不能只问有没有列表 |
| 复杂能力 | 常见表单、弹层、菜单、导航与输入 primitives | 另有 Table、Tree、GridList、Virtualizer、日期、颜色与 Drag and Drop | 后者可能减少业务侧补齐工作 |
| 视觉接口 | 各 part、state callback、data attributes、CSS variables | 各 part、Render Props、data attributes、默认 class 与 Slot | 两者均适合自建设计系统 |
| 国际化 | DirectionProvider、NumberField 的 locale 等局部支持 | 日期模型、日历、排序过滤、数字、RTL 等完整工具链 | 多地区产品中,日期和输入行为的成本差距更值得关注 |
| 性能判断 | 部分复杂组件通过 store selector 订阅 | Collection 缓存与 Virtualizer 等减少特定工作 | 都有优化机制,没有统一的全库快慢排名 |
本表依据下文的一手资料归纳。“容易发现”“减少补齐工作”等是对 API 结构的工程判断,不能解释成实测开发效率或性能成绩。
底层原理:输入怎样变成一次有效操作
Base UI:以具体模式组织交互与状态
以 Dialog 为例,Root 建立稳定的 store,再通过 Context 提供给 parts。Trigger、Popup 等读取需要的状态;内部逻辑处理 dismiss、滚动锁定与焦点。发生开关请求时,先让业务回调观察或取消,再更新内部状态。Dialog Root、DialogStore
下面的顺序对应 DialogStore.setOpen 的关键分支,省略其他通知与动画处理:
sequenceDiagram
participant T as Trigger / dismiss 逻辑
participant S as DialogStore
participant A as 应用回调
participant P as Dialog parts
T->>S: 请求 nextOpen 与 eventDetails
S->>A: onOpenChange(nextOpen, eventDetails)
alt eventDetails.isCanceled
S-->>T: 结束,不执行此次内部变更
else 未取消
S->>S: 通知交互层并更新内部状态
S-->>P: selector 读取最终 open
end
Note over S,P: Controlled Component 仍以外部 open 为准
A state-change callback is a request boundary. In controlled mode, the external prop remains the source of truth.
open、mounted、transitionStatus 分开管理:逻辑关闭不必立刻卸载 DOM,退出动画可以继续运行。store 通过 selector 订阅分发状态;底层使用 useSyncExternalStore 系机制。因此,“它用了 Context,所以一次状态变化必然使所有部件重渲染”不是对源码的准确描述。Popup store、store subscription
这套结构的价值在于:开发者能围绕一个具体控件理解行为归属。代价是组合出新模式时,要继续理解这个模式的 parts、状态、挂载和焦点约定;不能把任何两个看似相似的 part 随意互换。
React Aria:把状态、交互与组件组装分层
React Aria 的 Hooks 输出需要放到 DOM 上的 props;React Stately 管理对应状态;RAC 把它们组装起来,再提供 Context、Slot 和渲染接口。实际实现会按组件需要选择这些层,并非每个 Button 都要创建一个 React Stately 状态对象。Hooks、RAC Button 源码
以 Button 为例,RAC 合并上下文 props,调用 useButton、useFocusRing、useHover,生成 Render Props 与 data-pressed、data-hovered、data-focus-visible 等属性,然后输出节点。业务方拿到的是已经解释过输入方式的状态,不必把鼠标、触摸和键盘状态重新拼一遍。
最能体现这条路线的是 usePress。它处理 pointer、Enter、Space 和 screen reader virtual click,并考虑滚动取消、移出目标、返回目标、避免重复触发等条件。按下后拖出再释放,不能当成一次成功激活;当前源码也不能简单画成“pointerup 立即执行 onPress”,其中存在等待 click 的路径。usePress、固定版本源码
这并不意味着 Base UI 的 onClick 只能处理鼠标。原生 button 已有键盘行为,Base UI 也有自己的交互实现。差别是:React Aria 把统一的 PressEvent 及其生命周期作为可复用的公开接口提供;Base UI 的公开入口更多围绕具体组件的 DOM 事件与状态变更。
API 现代化:两者都现代,但优化的工作不同
render:能力接近,协议仍有差别
Base UI 常见写法是把自己的表现组件作为元素传入:
<Menu.Trigger render={<MyButton size="md" />}>
打开菜单
</Menu.Trigger>
也可以使用函数,取得 DOM props 与 state。元素形式在内部使用 cloneElement,函数形式由调用方生成节点;后者需要自己保证 props 与 ref 都到达真实 DOM。useRender 让自建组件也能继续暴露这套组合协议。Composition、useRender、渲染源码
当前 RAC 同样支持:
<Button
render={(domProps, { isPressed }) => (
<button {...domProps} data-custom-pressed={isPressed || undefined} />
)}
>
保存
</Button>
这与 children={(state) => ...} 有区别:render 接管根节点,children Render Prop 通常只生成其内部内容。RAC 还提供 composeRenderProps,用于包装组件时扩展 className、style 或 children,同时保留调用方传函数的能力。Customization
Replacing a rendered element does not replace its interaction contract. Props, refs, expected element semantics, and a single root still matter.
例如 RAC 要求保留期望的元素类型。Base UI Button 也明确不应通过 render 变成链接:nativeButton={false} 表示由非 button 节点承载 button 语义,不是把 Button 变成 Link 的开关。导航需要按钮外观时,应直接给 <a> 加样式。Base UI Button
显式 parts 与通用 Context / Slot
Base UI 的 NumberField.Increment、Dialog.Trigger 等命名把用途放在组件名上。局部阅读、IDE 补全和建立 wrapper 时比较直接。
RAC 会复用通用 Button,再用 slot="increment" 或 slot="decrement" 从 Context 取得具体行为。公开的 Context、useContextProps 和状态 Context 还允许自建部件参与已有模式。RAC Customization、Base UI NumberField
工程判断是:Base UI 的显式结构更容易在单个文件里看出部件职责;RAC 的通用部件更适合跨多个控件复用设计系统包装。后者也要求阅读者追踪 Slot 与 Context 的行为来源。不能只按组件名长短判定哪套 API 更先进。
可取消变更是 Base UI 一个值得重视的设计
很多 Base UI 回调传入 eventDetails,包含原因、原始事件及取消方法。例如 Tooltip 可以按 reason 拒绝一次由 trigger press 引发的开关请求,而不用为了这个局部策略把整个组件改成受控。Customization
这里至少有三种不同操作:
| 操作 | 改变什么 | 不应混同的事情 |
|---|---|---|
event.preventDefault() | 浏览器默认行为 | 不等于取消组件全部内部逻辑 |
event.preventBaseUIHandler() | Base UI 合并链中后续内部 handler | 不会自动调用 preventDefault 或 stopPropagation |
eventDetails.cancel() | 当前组件的状态变更请求 | 不会覆盖父组件已传入的 controlled value |
React Aria 的 onPress 公开另一种边界:PressEvent 默认阻止向父层传播,需要时调用 continuePropagation()。它解决的是交互传播,不是 Base UI 状态变更取消的同义 API。Base UI mergeProps、React Aria usePress
同名 mergeProps 不能互换
| 规则 | Base UI mergeProps(a, b) | React Aria mergeProps(a, b) |
|---|---|---|
| 普通重复字段 | 右侧覆盖 | 右侧非 undefined 值覆盖,id 等有特殊规则 |
| handler 顺序 | 先 b,再 a | 先 a,再 b |
| className | 结果通常为 b a | 结果通常为 a b |
| ref | 只保留右侧 ref | 当前版本会合并 refs |
| 自有取消协议 | 支持 preventBaseUIHandler() | 不能假设识别这一协议 |
这是包装组件时会影响正确性的差异。Base UI 的 useRender 另有 ref 合并处理,不能据此推断其独立 mergeProps 也合并 ref。两者的 className 拼接也不等于解决 Tailwind utility 冲突;样式冲突仍取决于构建产物和专门的合并工具。Base UI mergeProps、React Aria mergeProps
Pending 与状态化外观:RAC 的语义层也在继续演进
RAC Button 的 isPending 并非给 disabled 换个名字:它在禁止激活时保留可聚焦性,并将 pending 变化提供给辅助技术;同时通过状态接口让调用方表达等待外观。Base UI Button 则提供 focusableWhenDisabled 等基础控制,等待文案、业务状态和相应的可访问名称由包装层组织。两者分别偏向已经定义的行为语义和更基础的控制能力。RAC Button、Base UI Button
TypeScript:比较类型所表达的模型
Base UI 的 Dialog.Root.Props、Popover.Positioner.State、Combobox.Root.ChangeEventDetails 把类型挂到部件命名空间,建立 wrapper 和读取状态比较直接。RAC 则大量使用泛型 items、Key、Selection 和日期值类型,帮助表达数据和行为约束。Base UI TypeScript、RAC ListBox、RAC DatePicker
它们都不是“只为 React 19 才设计”的库:Base UI 当前声明支持 React 17–19,RAC 支持从 React 16.8 到 19 的相应版本。支持旧 React 不能直接推出 API 过时;是否适合项目,要看组合协议、语义完整性、可扩展性与包装成本。Base UI package、RAC package
Collection:复杂应用中,比 JSX 风格更大的差别
Base UI 已经有数据适配,不能再说它只有 DOM 注册
Combobox.createItems 从原始记录提取稳定 value 和 label,列表渲染仍取得原始记录。下面是机制示意,省略 Input、Portal、Popup 等完整结构:
const users = [
{ id: 'u1', name: 'Alice' },
{ id: 'u2', name: 'Bob' },
];
const items = Combobox.createItems(users, {
getValue: (user) => user.id,
getLabel: (user) => user.name,
});
<Combobox.Root items={items}>
<Combobox.List>
{(user) => (
<Combobox.Item key={user.id} value={user.id}>
{user.name}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Root>;
这样可以把业务对象与选择身份分开,避免异步刷新更换对象引用后误判同一项。动态数据应稳定创建 collection;外部 filteredItems 仍传原始 records,不能把它换成一组派生 ID。不用 createItems 而直接使用对象值时,也要理解默认比较及 isItemEqualToValue。Combobox、createItems 源码
Base UI 还通过 CompositeList 注册 DOM refs、label 与 metadata,按真实 DOM 顺序组织导航。createItems 与 DOM 注册解决不同层的问题;它们的存在都不能直接等同于 RAC 跨组件的 Collection 体系。CompositeList
RAC 让 Collection、Selection 和布局行为共享模型
ListBox、Menu、Table、Tree 等可以围绕稳定 Key、Collection 和相关状态工具组织行为。身份、焦点、选择、禁用和范围选择的基础模型可以在多个模式间复用,各模式仍有自己的语义要求。Collections、Selection
这不是仅在 .map() 外加一个组件。当前 CollectionBuilder 会把声明的 children 建立为 Collection model,客户端借助自定义 Document 与 React portal 维护集合节点,再通过 useSyncExternalStore 订阅不可变快照。这里的内部节点不等于在页面上渲染两套可见 DOM。随后,keyboard delegate 根据集合和布局寻找下一个 Key,SelectionManager 分别更新焦点与选择;Virtualizer 也可以消费同一集合。这解释了它为何能跨多种控件共享规则,也解释了额外的内部模型与学习成本。CollectionBuilder、useSelectableCollection、SelectionManager
这里有几个真实的工程约束:
- React 的
key服务于 reconciliation;RAC 的id还服务于 Collection 身份。手写.map()时要同时正确设置两者,不能认为提供 React key 就完成了集合标识。 - 动态 items 的渲染缓存依赖 item 对象身份。如果渲染读取额外外部状态,要声明
dependencies;更新某一个 item 时,替换该 item 对象比让全列表依赖全局状态更有针对性。 - ListBox 等多选模型的
selectedKeys可以是Set<Key>,也可以是'all'。'all'能表达尚未全部加载的数据全集,批量操作不能直接把它当数组遍历。 - 焦点和选择不是同一个状态。
selectionBehavior="toggle"与"replace"对方向键、修饰键和触摸的解释不同,业务验收要覆盖这些规则。
这些约束增加初学成本,但在文件管理、复杂后台、可编辑表格等场景中有明确用途。普通设置页只有几个 Select 时,未必需要为整个数据模型付出同样成本。
还应避免新的泛化:RAC 当前 Select 使用 value/defaultValue/onChange,多选值是 Key 数组;这不等于所有 Collection 控件都改成这一形状。 不能把旧 selectedKey 教程直接作为当前推荐,也不能把 ListBox 的 Set 模型硬套给 Select。Select
虚拟化不只是把 DOM 减少
RAC 提供 Virtualizer,与 Collection 组件配合管理布局和可见项。Base UI Combobox 也支持配合外部虚拟化,并提供相关配置。差别在于是否共享更通用的集合与布局基础设施,不是“一个支持大列表,另一个不支持”。RAC Virtualizer、Base UI Combobox
选择时需要验证:当前焦点项被滚出视窗后如何处理,screen reader 获得的顺序和总数是否合理,异步分页后 Key 是否稳定,搜索后是否保留选择,以及选择态是否意外依赖某个 DOM 节点仍然挂载。
Overlay 与 Focus Management
Base UI 把 Portal、Positioner、Popup、Backdrop 等职责显式拆开。定位实现组合 Floating UI 相关的 offset、flip、shift、size 和 autoUpdate;Dialog 同时处理 dismiss、滚动锁定、嵌套弹层与焦点恢复。它并非只提供一个绝对定位容器。定位源码、Dialog
React Aria 提供 Overlay、Modal、Popover、Dialog、FocusScope 等组合与底层工具。统一的焦点和交互语义是其基础设施的一部分;如果需要特殊结构,可以使用 Hooks,但下沉后要自行正确连接返回 props、refs 和状态。Modal、Popover、FocusScope
实际差别需要在产品结构里验证:Dialog 内 Select、Menu 内 Dialog、连续嵌套 Esc、触摸打开含输入框的弹层、退出动画期间恢复焦点。Base UI 的默认初始焦点还会考虑触摸输入,避免机械聚焦 input 后立即唤起软件键盘。Dialog Popup 源码
跨库混用时,应让一个控件的行为由同一个内核负责。例如可以在页面里分别使用 HeroUI Button 与 Base UI Dialog,但不要在同一个 trigger 节点无目的地叠加两套 Button 交互;同时核对 Portal、焦点恢复、Outside Press 和 z-index 的边界。
日期、国际化与能力覆盖
React Aria 的优势不只是组件较多。DateField、DatePicker、RangeCalendar 与 @internationalized/date 共同处理分段输入、日历、日期和时区值。CalendarDate、CalendarDateTime、ZonedDateTime 表达不同语义,不能一律当成 JavaScript Date 或 ISO 时间戳互换。DatePicker、Date and time
具体来说,CalendarDate 只有日期,CalendarDateTime 有日期和时间但没有时区,ZonedDateTime 则包含时区。DateField 还区分显示与值:可以用另一种日历显示,变更回调仍遵守传入值的日历类型约定。DateField
例如生日适合日期语义;跨地区会议需要日期、时间和时区语义。先选对值类型,再讨论如何存储和提交,可以避免仅在显示时格式化、实际却把日期偏移一天的问题。日期范围、非公历日历、RTL 和多语言输入也是同一选型的一部分。
Base UI 有 DirectionProvider、支持 locale 的 NumberField 等能力,不能说它“没有国际化”。但在本文版本,其组件目录未提供与 RAC 对等的一整套 DatePicker、Calendar、Table、Tree、颜色和 Drag and Drop 系统。项目真需要这些能力时,应把新增依赖、交互一致性和维护成本算进比较。Base UI 组件目录、DirectionProvider、NumberField、RAC 组件目录
React Aria 的广度也不是免费收益:团队需要接受更多值类型、状态约定与 Context 协议。只要需求范围清楚,使用 Base UI 加一个专门库也是合理路线;判断依据应是总工程成本。
表单、SSR 与 React Server Components
两者都已有表单基础设施。Base UI 的 Form、Field 围绕原生 Constraint Validation 扩展,支持字段关联、自定义验证、服务端 errors 及现代 React 表单组合。RAC 默认 validationBehavior="native" 会阻止无效提交;改为 "aria" 时保留相关语义,由应用或表单库控制提交验证。RAC 的 Form.validationErrors 接收服务端字段错误,也能结合 React 19 action 与 useActionState。它们都不能替代服务端业务验证。Base UI Forms、RAC Forms
建立表单 wrapper 时,要区分“用户当前输入”“解析后的业务值”“验证状态”和“提交状态”。NumberField、DateField 等不能只透传一个字符串就假定与普通 Input 等价。改变 headless 内核也可能影响隐藏 input、name/value、required 和表单重置。
SSR 与 React Server Components 同样需要拆开:SSR 能输出 HTML,不代表交互逻辑在服务器运行。两库的交互组件仍要在浏览器 Hydration;使用 'use client' 也不等于只能 CSR。RAC Framework setup、Base UI Quick start
调用方传入的普通函数 Render Prop 也不能任意跨越 Server / Client 序列化边界。需要状态回调与交互的组合,应在合适的 Client Component 内建立;“支持 React Server Components”不能用来推导任何 JSX 组合都能在服务器直接运行。
React Aria 在 React 18+ 利用内建 ID 能力,不再需要 SSRProvider;React 16 / 17 的 SSR 才需要该 Provider。locale、日期、数字格式也要保证服务端与客户端一致。Base UI 的 React 17 fallback ID 与 React 18+ 并不完全相同;有首屏关联需求时不能只看 React peer 范围。SSRProvider、Base UI useId
Base UI 的部分尺寸相关部件使用 prehydration script,严格 CSP 环境还要核对 CSPProvider。Headless 不代表没有测量、定位、预水合或运行时成本。CSPProvider、PrehydrationScript
React Aria 的国际化资源也有构建成本;官方提供 locale 裁剪方案。比较 bundle 时应说明保留了哪些 locale,不能一侧使用完整国际化资源,另一侧仅提供单语言交互,再把差值全部归因于组件实现。Framework setup
HeroUI 样式能否用于自建 Base UI
官方支持的是独立视觉层
HeroUI Composition 明确介绍了两种独立使用方式:直接使用 BEM classes,或调用 @heroui/styles 的 variant functions。buttonVariants 返回 className,checkboxVariants 返回多个 slot 的 class 生成函数。只调用函数并不会自动加载 CSS,也不会获得组件行为。HeroUI Composition、Styling
三条路径的责任不同:
| 路径 | 行为由谁负责 | 是否替换 headless 内核 |
|---|---|---|
| HeroUI React 组件 + 自定义 class / tokens | HeroUI / React Aria | 否 |
HeroUI React 组件的 render | HeroUI / React Aria,调用方接收 DOM props | 否 |
Base UI + @heroui/styles | Base UI;应用适配视觉状态与结构 | 是 |
Styles can be reused across behavior libraries. DOM structure and state selectors remain part of the styling contract.
@heroui/styles@3.2.6 没有 React peer,要求 Tailwind CSS 4+,并依赖相应的 variant 与动画样式工具。只借视觉层无需安装 @heroui/react;HeroUI React 自身的 React 19 要求也不能直接套到这条路径。styles 包元数据、React 包元数据
Button:可以直接接
以下适用于已经接通 React、TypeScript 与 Tailwind CSS 4 的项目。安装版本固定在本文快照,CSS 入口需要由 Tailwind 处理:
npm install @base-ui/react@1.9.0 @heroui/styles@3.2.6
/* globals.css:在应用入口导入此文件 */
@import "tailwindcss";
@import "@heroui/styles";
import { useState } from 'react';
import { Button as BaseButton } from '@base-ui/react/button';
import { buttonVariants } from '@heroui/styles';
export function SaveExample() {
const [saved, setSaved] = useState(false);
return (
<BaseButton
type="button"
disabled={saved}
className={buttonVariants({ variant: 'primary', size: 'md' })}
onClick={() => setSaved(true)}
>
{saved ? '已保存' : '保存'}
</BaseButton>
);
}
预期结果:显示 HeroUI primary Button 外观,激活后文字变成“已保存”并禁用。组件仍使用 Base UI 的 disabled、onClick;不会因为样式来自 HeroUI 就变成 isDisabled、onPress。Base UI Button
这种默认原生 button 的接法无需重建 hover / pressed 状态:HeroUI Button CSS 也提供 :hover、:active、:focus-visible 及 disabled / aria-disabled 选择器。Base UI Button 的 state 仅有 disabled,不能照搬 HeroUI Render Prop 中的 isPressed、isHovered。HeroUI Button CSS
导航按钮则直接写 <a href="/settings" className={buttonVariants({ variant: 'primary' })}>设置</a>,保留 link 语义。不要使用 Base UI Button 把链接伪装成操作按钮。
已有成熟站点接入时,应评估完整 HeroUI styles 入口包含的基础样式对正文和其他界面的影响;可以按官方导出拆分主题、基础与组件样式,不能只为了一个 Button 随意改变全站 CSS 顺序。HeroUI Styling
Checkbox:名字相近,属性协议并不相同
| 状态 | Base UI | HeroUI 3.2.6 视觉层常用条件 |
|---|---|---|
| 选中 | checked,输出 data-checked="" | 外层 .checkbox[data-selected="true"] |
| mixed | indeterminate,输出空值 data-indeterminate | 外层 data-indeterminate="true" |
| 禁用 | disabled,输出空值 data-disabled | 字符串值 data-disabled="true" 等条件 |
| 视觉主体 | Checkbox.Root 与 Checkbox.Indicator | .checkbox 下的 .checkbox__control 与 indicator |
Base UI 在 indeterminate 开启时不输出 data-checked / data-unchecked,只表达 indeterminate。适配器应保留这一覆盖关系,不能把表格理解为这些属性在所有状态下同时出现。Checkbox 状态映射
[data-indeterminate] 与 [data-indeterminate="true"] 是不同选择器。前者能匹配空字符串,后者不能;把属性放在 .checkbox__control 上,也不能满足 CSS 所需的祖先条件。Base UI Checkbox、状态属性生成、HeroUI Checkbox CSS
HeroUI 当前 CSS 对部分选择状态另有 aria-checked fallback,这可能让某些外观碰巧生效。但一项 fallback 不代表 mixed、禁用、图标动画、焦点和 DOM 结构都已兼容。不要通过覆盖 Base UI 自己的同名属性来补齐样式;独立视觉 wrapper 更容易维护。HeroUI Checkbox 实现
Anatomy:状态相同,为什么 CSS 没有响应
下面解剖的是 Base UI 的状态属性输出与 HeroUI Checkbox CSS 的匹配边界。输入是 checked / indeterminate 及适配方式,输出是浏览器能否匹配选中样式。插接台是教学比喻:连接对应属性名、属性值和祖先位置是否满足选择器,不表示组件库内部真的存在这些硬件。
先选择选中或 mixed,再依次切换“不映射”“正确映射”和“状态放错层”。观察原始属性、输出属性、插接状态和右侧真实 CSS 预览:同一个布尔状态,只有正确投影到 CSS 约定的位置和值上,才能稳定驱动样式。该模型重建视觉协议,不运行 Base UI 的完整交互状态机。
换一套样式,需要接通哪几处?
左端接收 Base UI 形式的两个状态;中间转换属性名称与值;右端检查状态是否位于 .checkbox 祖先。命中规则后,真实 HeroUI CSS 改变下方 Control 的背景。
- 原始属性
data-checked=""- 送往视觉层
data-selected="true"
.checkbox[data-selected="true"] .checkbox__control.checkbox[data-indeterminate="true"] .checkbox__control状态名称、字符串值与祖先层级均匹配,HeroUI 的选中或 mixed 背景规则生效。
模型、源码与适用边界
输入属性依据 Base UI Checkbox;选择器与四个 slot 类来自 HeroUI Checkbox CSS,当前站点使用 @heroui/styles 3.2.6。预览直接渲染该包的 .checkbox、.checkbox__content 和 .checkbox__control,没有用自定义颜色模拟命中。
这是状态和 CSS 的教学重建,没有加载 Base UI runtime,也没有模拟键盘、隐藏表单 input、validation、disabled、焦点或图标动画。上方操作控件本身使用 HeroUI。真实跨库组件仍须由一种 headless 内核独占交互,并逐项适配 DOM、状态和样式。
一个有限范围的 Checkbox 适配实现
下面将状态放在独立 wrapper,由 Base UI Root 独占 checkbox 行为。label 中包含 Root 及其隐藏 input;图标使用自己的 SVG,不调用依赖 HeroUI Context 的 React Indicator。
import { useState } from 'react';
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
import { checkboxVariants } from '@heroui/styles';
const slots = checkboxVariants();
export function AgreementExample() {
const [checked, setChecked] = useState(false);
return (
<div
className={`base-hero-checkbox ${slots.base()}`}
data-selected={checked ? 'true' : undefined}
>
<label className={slots.content()}>
<BaseCheckbox.Root
name="agreement"
value="yes"
checked={checked}
onCheckedChange={setChecked}
className={slots.control()}
>
<BaseCheckbox.Indicator className={slots.indicator()}>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="m3 8 3 3 7-7" />
</svg>
</BaseCheckbox.Indicator>
</BaseCheckbox.Root>
同意条款
</label>
</div>
);
}
/* 放在 HeroUI 样式之后;为实际接收焦点的 Base UI Root 补齐视觉反馈。 */
.base-hero-checkbox .checkbox__control:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
这段例子只覆盖二态选中与焦点外观。要支持 mixed、disabled、invalid 等,应从同一份受控状态派生 wrapper 属性与 Base UI props,再核对所有相关选择器。它不会自动继承 HeroUI 原版的完整动画、触屏 hover / pressed 归一化或字段错误布局。Select、Combobox 和 Dialog 的适配面更大,还要检查位置变量、Portal、动画挂载时机、滚动区域与焦点恢复。
不要为补一个样式槽而在 Base UI 树下加入 HeroUI Checkbox 的行为组件;那会把一次视觉适配扩大成两套交互逻辑的组合。
性能、稳定性与维护:如何公平评估
“Headless”“新的 API”“使用 Floating UI”都不能推出更小 bundle 或更低延迟。Base UI 的 selector、RAC 的 Collection 缓存和 Virtualizer 各有明确用途;最终成本取决于实际 import、组件组合、运行数据与构建设置。
建议使用同一份业务页面与数据,检查以下结果:
| 场景 | 关键操作 | 通过标准 |
|---|---|---|
| Dialog 内 Select / Combobox | 键盘打开、选择、Esc、关闭 Dialog | 焦点回到正确 trigger,不重复关闭父层 |
| 异步搜索 | 中文输入、请求乱序、重新取数、分页 | 输入法不被打断,选项身份稳定,选择不丢失 |
| 大列表 | 搜索、滚动、方向键移动 | 当前项可达;焦点和辅助技术信息与集合一致 |
| 表单 | label 点击、Space、disabled、required、reset、FormData | 原生提交值和视觉状态一致,错误关联可读 |
| HeroUI 跨库样式 | checked、mixed、focus、disabled、dark、reduced motion | DOM 与选择器协议完整,关键状态均有可见反馈 |
| 构建与运行 | 固定版本打包、首次打开弹层、更新 items | 记录实际 JS/CSS、长任务与提交次数,再判断瓶颈 |
包体比较必须写清 bundler、压缩方式、组件集合和依赖去重条件。已有 HeroUI 的项目再加入 Base UI,也应测量两个内核共同存在的增量,不能拿两个独立空项目的数字替代。
无障碍验收也不能只靠静态扫描。两库都投入了无障碍实现,最终产品仍受 label、contrast、focus appearance、自定义 DOM、键盘路径及辅助技术行为影响。Base UI Accessibility、React Aria Quality、Testing
稳定性方面,版本号和维护历史只能提供背景。更值得检查的是:项目需要的组件是否已覆盖、升级说明是否清楚、wrapper 是否依赖未公开 DOM、是否需要复制大量选择器,以及团队是否能持续运行上述回归。
选型判断
优先考虑 Base UI: 主要需求是常见控件、导航、菜单与浮层;希望显式掌握每个 part;对 render 组合、按原因取消变更及自建设计系统包装有较高要求;能接受为日期、表格等缺口补充其他实现。
优先考虑 React Aria / RAC: 复杂数据选择、列表 / 表格 / 树、键盘多选、跨设备行为、多地区日期输入等是产品的重要部分;希望多个控件共享一套 Collection、Selection、Focus 与国际化模型;愿意理解这些抽象。
已经采用 HeroUI: 常规控件继续用 HeroUI,少数确有行为缺口的组件再评估 Base UI + @heroui/styles。如果目标只是改变布局、节点或动画,先检查 HeroUI / RAC 当前的 render、Context 和样式接口能否满足。全量替换意味着重新维护状态桥接、结构、焦点、表单与升级回归;只有获得了明确的行为或工程收益,这个成本才有理由承担。
专业选型中,Base UI 更吸引人的地方是具体模式的部件控制与组合体验,React Aria 更难替代的地方是跨模式复用的交互与数据语义。选择哪条路线,应由产品最复杂的几类真实交互决定。
本文示例的实际验证
2026-10-16,使用 React 19.2.8、Base UI 1.9.0 和 HeroUI styles 3.2.6,从本文提取 SaveExample 与 AgreementExample 原样进行 strict TypeScript、SSR 与 Chromium 检查,均通过。确认了保存后禁用、label 点击只切换一次、Space 切换、原生隐藏 input、Indicator 挂载、选中时提交 agreement=yes、未选中时省略该值,以及 wrapper 状态驱动 HeroUI CSS;文中的 focus-visible CSS 也产生了可见 outline。另一个等价案例确认 disabled Checkbox 不切换并离开 Tab 顺序。
该轮浏览器验证使用包内编译后的 heroui.min.css,未单独重跑示例项目的 Tailwind 源 CSS 构建,也未进行跨浏览器、完整读屏或两库性能基准。Anatomy 只解释独立的状态选择器边界,不代替这些组件行为检查。
源码与关联阅读
- Base UI v1.9.0:本文固定的 Base UI 发布源码。
- React Spectrum 固定提交:RAC 1.22.1 / React Aria 3.53.1 对应源码。
- HeroUI Composition:独立样式、variant functions、BEM 与
render的官方边界。 - Frontend Overview:相关前端主题导航。
- React Native UI 方案横评:移动端组件体系另行比较,不能把 Web Headless 结论直接迁移到原生端。