标签页家族。行为全部由 Base UI 承担:TabsList 内是一个 roving focus
(整组只占一个 Tab 停留点),方向键在标签间移动并随 orientation 换轴,
TabsTab 与 TabsPanel 的 aria-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>TabsTab 的 value 就是它的 key,同名 value 的 TabsPanel 自动配对;
defaultValue 指定初始选中(不给则选第一个未禁用的标签)。
TabsList 的 aria-label 必给 —— 它是这组标签页的无障碍名。
会滑动的选中标记(TabsIndicator)和面板的交叉滑动
(TabsViewport)默认都在场,上面这段代码一个字都不用加。
组成
结构永远是:Tabs 里放一个 TabsList(内含若干 TabsTab),后面跟若干 TabsPanel。
Tabs
├── TabsList
│ ├── TabsIndicator ← 默认自动渲染
│ ├── TabsTab
│ └── TabsTab
└── TabsViewport ← 默认自动包裹
├── TabsPanel
└── TabsPanel带 ← 的两个部件是封装层隐式渲染的:写不写都在,写了就以你写的那只为准, 也可以显式关掉 —— 见滑动指示器与面板切换动画。
Line
TabsList 的 variant 只换外观,不改行为: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 属性 —— TabsTab 是原生 <button>,直接用 hover: /
active: / focus-visible: 伪类,覆盖全部输入模态。
TabsIndicator 是这条规则的例外:它是纯 span,className 只收字符串,也没有任何状态
(见 Props 里那一节)。children 都是普通 ReactNode ——
Base UI 没有集合 API,也没有状态 render props,按状态换内容就自己拿 value 判断。
需要从外部定位时用 data-slot:
键盘交互
禁用的标签在以上所有导航中都会被跳过。
Props
顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。
每个部件的状态属性与函数 className 的参数见状态与 className。
Tabs
TabsList
接缝层只往上加了一个 relative —— TabsIndicator 绝对定位在它里面,需要这个定位上下文。
TabsIndicator
写在 TabsList 里,位置随意 —— Base UI 的 List 原样渲染 children,所以它渲染在你
写的地方,测量的正是它自己的父元素。它没有状态:跟随哪个标签是从 DOM 上读出来的
([data-active]、:focus-visible、一个指针监听),外部读不到,也不该读。
TabsTab
TabsViewport
面板舞台:里面的面板叠进同一格 grid 单元。根组件默认隐式创建一个,自己写就是 接管结构,见面板切换动画。
TabsPanel
TabsProps / TabsListProps / TabsTabProps / TabsPanelProps /
TabsIndicatorProps / TabsViewportProps 六个类型也一并导出,业务层再包一层时
直接复用。