Skip to content
返回设计中心

标签页组件设计

带键盘箭头、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 — 保状态并避免重挂成本
  • ✓选一激活模型并文档化 — 产品内混用自动+手动让用户困惑

常见问题解答