标签页组件设计
带键盘箭头、ARIA 角色与受控/非受控模式的无障碍 tablist。
组件模式进阶
为什么
标签页把相关内容折到一个区域,减少纵向滚动,让用户比较区段而不丢上下文。建错则困住键盘用户并破坏屏幕阅读器播报。
复合组件模式被 Radix UI、Headless UI 和 shadcn/ui 采用 — 这是 2024 年采用最多的三个无头库。相比扁平组件 API,它们减少 70% 的属性逐层传递。
何时使用
用于 2-6 个共享上下文的同级区段(设置 → 资料/账单/安全)。顺序流程用 Stepper;>6 区段用侧边栏导航。
如何做
1. 角色 — tablist、tab(aria-selected)、tabpanel(aria-labelledby 指向 tab id)
2. 键盘 — 左/右箭头在标签间移动、Home/End 跳首/末、Tab 键不离开直到面板
3. 激活 — 聚焦即激活(移动+激活)或手动(Enter/Space);选一并文档化
4. 状态 — activeTab id;非受控用内部 useState,受控取 value + onValueChange
5. 面板渲染 — 非活跃面板保持挂载加 hidden 以保状态,或卸载省 DOM
6. 焦点 — 激活时移焦点到新标签;面板关闭时恢复到标签
7. 动画 — 面板交叉淡入 150ms ease-out,尊重 prefers-reduced-motion
示例
function Tabs({ tabs, defaultTab }: { tabs: Tab[]; defaultTab: string }) {
const [active, setActive] = useState(defaultTab);
const onKeyDown = (e: React.KeyboardEvent) => {
const i = tabs.findIndex((t) => t.id === active);
if (e.key === "ArrowRight") setActive(tabs[(i + 1) % tabs.length].id);
if (e.key === "ArrowLeft") setActive(tabs[(i - 1 + tabs.length) % tabs.length].id);
if (e.key === "Home") setActive(tabs[0].id);
if (e.key === "End") setActive(tabs[tabs.length - 1].id);
};
return (
<>
<div role="tablist" onKeyDown={onKeyDown}>
{tabs.map((t) => (
<button key={t.id} role="tab" id={`tab-${t.id}`} aria-selected={active === t.id}
aria-controls={`panel-${t.id}`} tabIndex={active === t.id ? 0 : -1}
onClick={() => setActive(t.id)}>
{t.label}
</button>
))}
</div>
{tabs.map((t) => (
<div key={t.id} role="tabpanel" id={`panel-${t.id}`} aria-labelledby={`tab-${t.id}`}
hidden={active !== t.id}>{t.content}</div>
))}
</>
);
}
常见问题
- ⚠无 roving tabindex — 每标签都可 tab,Tab 键跳进面板而非下一标签
- ⚠切换时面板卸载 — 内部表单状态丢失,用户重输一切
- ⚠缺 aria-controls/aria-labelledby — 屏幕阅读器无法关联标签与面板
最佳实践
- ✓用 roving tabindex — 仅活跃标签 tabIndex=0,其余 -1,箭头移焦点
- ✓非活跃面板保持挂载加 hidden — 保状态并避免重挂成本
- ✓选一激活模型并文档化 — 产品内混用自动+手动让用户困惑