Cadenza
EN

Base UI Tabs 的封装 —— 组合式的标签页家族,行为与无障碍语义全部由 Base UI 承担,外加一块会跟着悬停与焦点滑动的指示器

标签页家族。行为全部由 Base UI 承担:TabsList 内是一个 roving focus (整组只占一个 Tab 停留点),方向键在标签间移动并随 orientation 换轴, TabsTabTabsPanelaria-controls / aria-labelledby 互指按 value 自动接线。 组合就是全部 API —— 根组件 Tabs 上没有 items,也没有配置对象。 标签集合由数据算出来时就是一次普通的 .map()

公开名对齐这些部件真正的身份:Tabs / TabsList / TabsTab / TabsPanel。 shadcn 源码里那套 TabsList / TabsTrigger / TabsContent 是 Radix 留下的别名 —— 底下明明是 Base UI 组件,名字却是另一个库的。接缝层刻意把它改回来, 这样 props(value / onValueChange / activateOnFocus)和组件名说的是同一门方言。

使用

import {
  Tabs,
  TabsIndicator,
  TabsList,
  TabsPanel,
  TabsTab,
  TabsViewport,
} from '@gedatou/cadenza-ui'
<Tabs defaultValue="overview">
  <TabsList aria-label="项目仪表盘">
    <TabsTab value="overview">概览</TabsTab>
    <TabsTab value="analytics">分析</TabsTab>
  </TabsList>
  <TabsPanel value="overview">项目整体进度与本周待办。</TabsPanel>
  <TabsPanel value="analytics">按周聚合的访问量与转化趋势。</TabsPanel>
</Tabs>

TabsTabvalue 就是它的 key,同名 valueTabsPanel 自动配对; defaultValue 指定初始选中(不给则选第一个未禁用的标签)。 TabsListaria-label 必给 —— 它是这组标签页的无障碍名。

会滑动的选中标记(TabsIndicator)和面板的交叉滑动 (TabsViewport默认都在场,上面这段代码一个字都不用加。

组成

结构永远是:Tabs 里放一个 TabsList(内含若干 TabsTab),后面跟若干 TabsPanel

Tabs
├── TabsList
│   ├── TabsIndicator   ← 默认自动渲染
│   ├── TabsTab
│   └── TabsTab
└── TabsViewport        ← 默认自动包裹
    ├── TabsPanel
    └── TabsPanel

带 ← 的两个部件是封装层隐式渲染的:写不写都在,写了就以你写的那只为准, 也可以显式关掉 —— 见滑动指示器面板切换动画

Line

TabsListvariant 只换外观,不改行为:default(默认)是胶囊底色, line 去掉底色、改成标签下方一条 2px 指示线:

滑动指示器

TabsIndicator 把选中标记从「每个标签各画各的」换成一个会滑动的元素: 切换标签时它补间过去(200ms ease-out),而不是 A 灭 B 亮。

它跟随的顺序是 悬停 → 聚焦 → 选中:鼠标移到别的标签上它就滑过去, 移开再滑回真正选中的那个,所以它表达的是「你即将选中的那个」。 中间那档「聚焦」正是它存在的理由 —— Base UI 默认手动激活,方向键只移焦点不改选中, 指示器跟着焦点走才看得出来将要选中谁。

Base UI 自己也有 Tabs.Indicator,但它只跟随已选中的标签。这块是本库另写的, 多出来的就是悬停与焦点那两档。

它默认在场——TabsList 自动渲染一只,这就是本库 tabs 的长相,不用写。三态:

<TabsList aria-label="…"></TabsList>                    // 不写:默认在场
<TabsList aria-label="…">
  <TabsIndicator className="…" />                        // 写:完全归你,默认那只让位

</TabsList>
<TabsList aria-label="…" indicator={false}></TabsList>  // 显式关:回到各画各的选中态

指示器在场时标签自身不再画选中底色,两个 variant 都支持 (default 是滑动的胶囊,line 是滑动的下划线)。 prefers-reduced-motion 下自动降级为瞬时移动;首次渲染直接就位,不做滑入动画。 标签宽度变化(字体加载完、角标数字变了)会自动重新测量。

面板切换动画

交叉滑动默认在场——Base UI 官方 animated panels 的同款参数(opacity 175ms ease、位移 350ms cubic-bezier(0.22,1,0.36,1)、 ±50% 行程):旧面板向一侧滑出淡出,新面板同时从新标签所在的那一侧滑入 (方向来自 data-activation-direction,纵向 tabs 是上下滑)。位移在 prefers-reduced-motion 下不做,淡入淡出保留。

官方示例要求手写一个 viewport 容器;这里根组件隐式代劳——连续的 TabsPanel 直接子元素(.map() 数组也算)被自动收进一个 TabsViewport, 面板叠进同一格 grid 单元,进出双方重叠着动、布局零跳动。三态照旧:

<Tabs></Tabs>                              // 不写:默认交叉滑动
<Tabs>
  <TabsViewport className="…">…面板…</TabsViewport>   // 自己写 viewport:结构归你
</Tabs>
<Tabs viewport={false}></Tabs>             // 关 viewport:回退免容器的进场微滑
<TabsPanel animated={false} …>              // 单面板关动画

边界:藏在自定义包装组件里的面板收不进 viewport(与所有隐式部件同款的 「直接子元素」边界),会回退到进场微滑。

方向

orientation="vertical"TabsList 竖排,方向键也跟着换轴(上下键切换)—— 换轴是 Base UI 读 orientation 自己做的,不用另外配。指示器同样换轴: 改成上下滑动、高度补间(line 变体下指示线也随之转到侧边):

受控

value + onValueChange 把选中态交给外部 state, 组件只负责渲染 —— 从 Tabs 之外切换标签页(下面的「下一个」按钮)就靠这个:

禁用

<TabsTab disabled> 逐个声明。禁用集合由数据算出来时就在 .map() 里写 disabled={disabledIds.has(id)} —— Base UI 没有根上的禁用集合。 禁用的标签会被键盘导航跳过,指示器也不会跟到它上面:

键盘激活方式

默认是手动激活:方向键只移动焦点,按 Enter / Space 才切换面板。 面板加载昂贵(发请求、渲染大图表)时,键盘路过不会白白触发一次。 面板内容便宜、想少按一次 Enter 时,在 TabsList 上开 activateOnFocus

这一条和 React Aria 的默认相反(那边默认焦点走到哪就切到哪)。从 0.2 升上来时 依赖旧默认的地方要显式加 activateOnFocus

状态与 className

className 一律双形态:字符串,或 (state) => string 的函数。下表左列是 Base UI 挂在 DOM 上的 data-*,Tailwind 直接当变体写(data-active:bg-background); 右列是同一个状态在函数 className 里的名字 —— 同一组状态的两个出口,按手头顺手的用。

部件data-*出现时机函数 className 里的名字
四个部件都有data-orientation始终有值:horizontal / vertical。根组件定,其余跟随orientation
四个部件都有data-activation-direction相对上一个选中项的方向:left / right / up / down / nonetabActivationDirection
TabsListdata-variant始终有值:default / line(我们加的,不是 Base UI 的)
TabsTabdata-active是当前选中的标签active
TabsTabdata-disableddisableddisabled
TabsPaneldata-hiddenkeepMounted 保留下来、但当前未选中的面板hidden

悬停、按下、焦点没有 data 属性 —— TabsTab 是原生 <button>,直接用 hover: / active: / focus-visible: 伪类,覆盖全部输入模态。

TabsIndicator 是这条规则的例外:它是纯 span,className 只收字符串,也没有任何状态 (见 Props 里那一节)。children 都是普通 ReactNode —— Base UI 没有集合 API,也没有状态 render props,按状态换内容就自己拿 value 判断。

需要从外部定位时用 data-slot

data-slot是什么
tabs根元素
tabs-list标签条
tabs-indicator滑动指示器(封装层自渲染)
tabs-trigger单个 TabsTab名字是 shadcn 源码留下的,部件已改名,选择器没有
tabs-viewport面板舞台(封装层自渲染)
tabs-panel单个 TabsPanel(接缝层按部件改的名,vendored 文件里写的是 tabs-content

键盘交互

按键行为
/ 横向(默认)时移动到相邻标签,到头循环
/ orientation="vertical" 时移动到相邻标签
Home / End跳到第一个 / 最后一个标签
Tab进入时落在选中的标签上(整组只占一个停留点),再按一次离开 TabsList 进入面板
Enter / Space确认当前焦点所在的标签(activateOnFocus 下方向键已经切过了)

禁用的标签在以上所有导航中都会被跳过。

Props

顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className

每个部件的状态属性与函数 className 的参数见状态与 className

Tabs

Prop类型默认值说明
defaultValuestring | number | null第一个未禁用的标签非受控初始选中
valuestring | number | null受控选中的标签值;null 表示一个都不选
onValueChange(value, eventDetails) => void选中变化回调
viewportbooleantrue是否把连续的 TabsPanel 直接子元素收进 TabsViewport,见面板切换动画
orientation'horizontal' | 'vertical''horizontal'排列方向,同时决定方向键的轴
classNamestring | (state) => string根元素类名
其余Base UI Tabs.Root 的 props透传

TabsList

Prop类型默认值说明
aria-labelstring这组标签页的无障碍名,必给
activateOnFocusbooleanfalse方向键是否顺带切换面板,见键盘激活方式
indicatorbooleantrue是否自动渲染一只 TabsIndicator;自己写了一只时这个开关不参与,见滑动指示器
variant'default' | 'line''default'胶囊底色 / 底部指示线
classNamestring | (state) => string类名
其余Base UI Tabs.List 的 props透传

接缝层只往上加了一个 relative —— TabsIndicator 绝对定位在它里面,需要这个定位上下文。

TabsIndicator

Prop类型默认值说明
classNamestring类名(改颜色、圆角、过渡时长都从这里来)

写在 TabsList 里,位置随意 —— Base UI 的 List 原样渲染 children,所以它渲染在你 写的地方,测量的正是它自己的父元素。它没有状态:跟随哪个标签是从 DOM 上读出来的 ([data-active]:focus-visible、一个指针监听),外部读不到,也不该读。

TabsTab

Prop类型默认值说明
valuestring | number必填这个标签的 key,与对应 TabsPanelvalue 相同(全等比较,没有按序号回退这回事)
disabledbooleanfalse禁用这一个标签
nativeButtonbooleantrue关掉它才能用 render 换成 <a>(路由驱动的标签页)
renderReactElement | (props, state) => ReactElement元素替换。优先函数形态
classNamestring | (state) => string类名
其余Base UI Tabs.Tab 的 props透传

TabsViewport

面板舞台:里面的面板叠进同一格 grid 单元。根组件默认隐式创建一个,自己写就是 接管结构,见面板切换动画

Prop类型默认值说明
classNamestring类名
其余div 的原生属性 + ref透传

TabsPanel

Prop类型默认值说明
valuestring | number必填与对应 TabsTabvalue 相同即自动配对
keepMountedbooleanfalse未选中时也保留在 DOM(默认只渲染选中的那个)
animatedbooleantrue这一个面板的切换动画;关掉只影响它自己
classNamestring | (state) => string类名
其余Base UI Tabs.Panel 的 props透传

TabsProps / TabsListProps / TabsTabProps / TabsPanelProps / TabsIndicatorProps / TabsViewportProps 六个类型也一并导出,业务层再包一层时 直接复用。