资讯动态

从交互本质到工程实现:构建可访问的选项卡组件

发布时间:2026/10/16 1:22:04 来源:尧图企业网站定制
1. 选项卡不是能点就行从交互本质倒推结构设计很多人第一次写选项卡脑子里想的是点一下换个内容于是很自然地写出一个能跑的版本几个按钮排一排下面几个div堆一起点谁谁显示。功能是有了但代码经不起看——加一个选项卡要改三处样式和结构搅在一起键盘用户根本用不了屏幕阅读器读出来是一团乱麻。我见过太多这样的实现包括我自己早期写的。问题不在于能不能跑而在于结构有没有把交互语义表达清楚。选项卡的本质是什么是一组互斥的视图切换同一时刻只有一个面板可见触发切换的是一组并列的标签。这个语义决定了三件事标签之间是平级关系标签和面板之间是控制关系面板之间是互斥关系。HTML里能同时表达这三层关系的元素组合其实只有一套最贴切——roletablist容器里放roletab的标签每个标签通过aria-controls指向对应的roletabpanel面板。为什么不用纯div加class因为div没有任何语义浏览器和辅助技术不知道你在干什么。你当然可以用class名硬编码一套约定但那是给看得见的人用的键盘用户按Tab键只会一个个跳过所有标签屏幕阅读器用户听到的是一串没有上下文的文本。用原生语义或者ARIA角色等于免费获得了一套交互契约浏览器知道这是选项卡辅助技术知道怎么播报键盘用户知道左右箭头可以切换。结构定下来之后CSS和JS的分工就清晰了。CSS负责长什么样和哪个可见JS负责谁被选中和状态同步。这里有个关键决策可见性切换用class还是用属性选择器我推荐用属性选择器配合hidden属性或者aria-selected状态。原因是状态和样式绑定在一起JS只需要改一个属性CSS自动响应不需要维护两套逻辑。比如[roletabpanel][hidden] { display: none; }这样JS里只需要panel.hidden true/false样式自动跟着走。如果用class你得同时记得加class、删class、处理初始状态多一个环节就多一个出错点。还有一个容易被忽略的点标签的排列顺序和面板的DOM顺序应该一致。有些实现为了布局方便把面板放在完全不同的位置然后用JS去查。这在小规模下没问题但一旦涉及动画、焦点管理、响应式重排顺序不一致会让代码变得极其脆弱。我的习惯是标签容器和面板容器作为兄弟节点面板顺序严格对应标签顺序这样用索引就能直接映射不需要额外的映射表。提示如果你打算用原生details或者select来模拟选项卡趁早放弃。前者是折叠面板语义后者是下拉选择语义和选项卡的并列互斥不是一回事硬套只会让辅助技术播报错误。2. 从零搭一个能用的选项卡HTML骨架与CSS状态机2.1 骨架少写一个属性后面就要多写十行JS先看骨架。我习惯把整个组件包在一个容器里标签列表用roletablist每个标签是button元素——注意用button而不是a或者div。button天然可聚焦、可键盘触发、有按下状态这些都是选项卡需要的。用a标签你得阻止默认跳转用div你得手动加tabindex和键盘事件纯属自找麻烦。div classtabs div roletablist aria-label内容切换 button roletab idtab-1 aria-controlspanel-1 aria-selectedtrue标签一/button button roletab idtab-2 aria-controlspanel-2 aria-selectedfalse标签二/button button roletab idtab-3 aria-controlspanel-3 aria-selectedfalse标签三/button /div div roletabpanel idpanel-1 aria-labelledbytab-1面板一的内容/div div roletabpanel idpanel-2 aria-labelledbytab-2 hidden面板二的内容/div div roletabpanel idpanel-3 aria-labelledbytab-3 hidden面板三的内容/div /div这里有几个细节值得说。aria-selected标记当前选中的标签初始状态只有一个为true。hidden属性直接加在非活动面板上浏览器默认就不渲染不需要额外CSS。aria-labelledby把面板和标签关联起来屏幕阅读器进入面板时会先读标签文字用户就知道自己在看哪个面板的内容。aria-label加在tablist上是为了给这组选项卡一个整体描述。如果页面上只有一组选项卡可以省略但如果有多个加上能避免辅助技术用户混淆。2.2 CSS用属性选择器驱动状态而不是堆classCSS部分的核心思路是所有视觉状态都由属性驱动。选中状态看aria-selected可见状态看hidden焦点状态用:focus-visible。这样JS只需要改属性样式自动响应。.tabs { max-width: 640px; margin: 2rem auto; font-family: system-ui, sans-serif; } [roletablist] { display: flex; gap: 4px; border-bottom: 2px solid #e0e0e0; } [roletab] { padding: 10px 20px; border: none; background: transparent; cursor: pointer; font-size: 15px; color: #666; border-bottom: 2px solid transparent; margin-bottom: -2px; transition: color 0.2s, border-color 0.2s; } [roletab][aria-selectedtrue] { color: #1a73e8; border-bottom-color: #1a73e8; font-weight: 600; } [roletab]:hover { color: #333; } [roletab]:focus-visible { outline: 2px solid #1a73e8; outline-offset: 2px; border-radius: 4px; } [roletabpanel] { padding: 20px 4px; line-height: 1.7; color: #333; } [roletabpanel][hidden] { display: none; }这段CSS里margin-bottom: -2px配合border-bottom是为了让选中标签的下边框和tablist的下边框重叠形成标签连着内容区的视觉效果。这是选项卡最经典的视觉语言用户一看就知道哪个标签是激活的。:focus-visible而不是:focus是因为鼠标点击时不需要显示焦点环只有键盘导航时才显示。这个细节很多实现都忽略了导致鼠标用户点击后看到一个突兀的轮廓线。2.3 初始状态别让JS来决定谁可见一个常见的错误是HTML里所有面板都可见等JS加载后再隐藏非活动的。这会导致页面闪烁——用户先看到所有内容堆在一起然后突然只剩一个。正确的做法是在HTML里就把非活动面板加上hiddenJS只负责后续的切换。同理aria-selected的初始值也直接写在HTML里不要等JS来设。这样即使JS加载失败页面至少是可读的——所有面板内容都在只是没有切换功能。这叫渐进增强是前端组件设计的基本素养。3. 键盘导航与焦点管理选项卡真正难的地方3.1 方向键切换不是可选项是必选项鼠标点击切换谁都会写但选项卡的键盘交互有一套明确的约定左右方向键在标签之间移动焦点Home键跳到第一个End键跳到最后一个。注意方向键移动的是焦点不是选中状态——这是很多实现搞混的地方。WAI-ARIA的选项卡模式有两种激活方式自动激活和手动激活。自动激活是焦点移到哪个标签就选中哪个手动激活是焦点移动后需要按Enter或Space才选中。两者都可以但自动激活更适合内容切换成本低的场景手动激活适合面板内容很重、切换有性能开销的场景。我一般用自动激活因为大多数选项卡面板就是一段文字切换没有成本。实现逻辑是监听tablist上的keydown判断按键计算目标索引把焦点移过去同时更新选中状态。const tablist document.querySelector([roletablist]); const tabs [...tablist.querySelectorAll([roletab])]; tablist.addEventListener(keydown, (e) { const currentIndex tabs.indexOf(document.activeElement); if (currentIndex -1) return; let targetIndex null; if (e.key ArrowRight) { targetIndex (currentIndex 1) % tabs.length; } else if (e.key ArrowLeft) { targetIndex (currentIndex - 1 tabs.length) % tabs.length; } else if (e.key Home) { targetIndex 0; } else if (e.key End) { targetIndex tabs.length - 1; } if (targetIndex ! null) { e.preventDefault(); tabs[targetIndex].focus(); activateTab(targetIndex); } });e.preventDefault()是必须的否则方向键会触发页面滚动。取模运算让左右键在首尾循环这是选项卡的惯例用户按到最后一个再按右键会回到第一个。3.2 焦点环和tabindex的配合这里有个容易踩的坑如果所有标签的tabindex都是0用户按Tab键会依次经过每一个标签这不符合选项卡的预期。正确的做法是只有当前选中的标签tabindex为0其余为-1。这样Tab键进入选项卡区域时直接落在选中的标签上然后方向键在内部切换再按Tab键离开整个组件。function activateTab(index) { tabs.forEach((tab, i) { const selected i index; tab.setAttribute(aria-selected, selected); tab.tabIndex selected ? 0 : -1; const panel document.getElementById(tab.getAttribute(aria-controls)); panel.hidden !selected; }); }这个tabIndex的管理是选项卡键盘体验的关键。少了这一步键盘用户要按好几次Tab才能穿过标签栏体验很差。而且这个逻辑必须在初始化和每次切换时都执行不能只在点击时处理。3.3 点击和键盘的事件统一点击事件和键盘事件最终都调用同一个activateTab函数这样逻辑只有一份不会出现鼠标能用键盘不能用或者键盘能用鼠标不能用的分裂情况。tabs.forEach((tab, index) { tab.addEventListener(click, () activateTab(index)); });初始化的时候也要调用一次activateTab(0)确保tabindex和aria-selected的状态和HTML里写的一致。虽然HTML里已经写了初始状态但JS初始化时再跑一遍能保证状态绝对同步避免手写HTML时漏掉某个属性。注意如果你的选项卡面板里有可聚焦元素比如链接、按钮键盘用户按Tab从标签进入面板后焦点会落在面板内第一个可聚焦元素上。这是符合预期的。但如果面板是空的或者只有文本焦点会直接跳到下一个组件。如果你希望焦点能停留在面板上可以给面板加tabindex0但这会让面板本身可聚焦需要权衡。4. 动态增删与内容懒加载选项卡进阶场景4.1 动态添加标签时索引映射不能靠位置静态选项卡用索引映射没问题但一旦支持动态增删索引就会错位。比如你删了第二个标签原来索引为2的标签变成了1但面板的id没变如果还用索引去查面板就会出错。正确的做法是用id关联而不是索引关联。每个标签的aria-controls指向面板id切换时通过id查找面板而不是通过索引去数组里取。这样无论标签怎么增删排序关联关系都不会断。function activateTabById(tabId) { tabs.forEach((tab) { const selected tab.id tabId; tab.setAttribute(aria-selected, selected); tab.tabIndex selected ? 0 : -1; const panel document.getElementById(tab.getAttribute(aria-controls)); if (panel) panel.hidden !selected; }); }动态添加标签时新标签的id和aria-controls要保证唯一。我习惯用一个自增计数器生成id比如tab-${counter}和panel-${counter}这样不会和已有id冲突。4.2 面板内容懒加载什么时候值得做如果选项卡面板内容很多比如每个面板都是一个复杂表格或者图表一次性全部渲染会导致初始加载慢。这时候可以考虑懒加载面板第一次被激活时才渲染内容。实现思路是给面板加一个标记比如>function activateTab(index) { // ... 切换状态 ... const panel document.getElementById(tabs[index].getAttribute(aria-controls)); if (panel panel.dataset.loaded false) { renderPanelContent(panel); panel.dataset.loaded true; } }但懒加载不是免费的。它增加了状态管理的复杂度而且如果用户快速切换标签可能会触发多次渲染。我的建议是只有当面板内容确实很重比如超过100个DOM节点或者需要网络请求时才用懒加载普通文本内容直接渲染就好没必要为了优化而增加复杂度。4.3 删除标签后的焦点归属删除当前选中的标签时焦点应该去哪里这是一个容易被忽略的边界情况。合理的做法是删除后自动选中相邻的标签优先选右边的如果没有就选左边的并把焦点移过去。如果删的是最后一个标签整个选项卡组件应该被移除或者显示空状态。function removeTab(tabId) { const tab document.getElementById(tabId); const index tabs.indexOf(tab); const wasSelected tab.getAttribute(aria-selected) true; const panel document.getElementById(tab.getAttribute(aria-controls)); tab.remove(); if (panel) panel.remove(); tabs.splice(index, 1); if (wasSelected tabs.length 0) { const nextIndex Math.min(index, tabs.length - 1); tabs[nextIndex].focus(); activateTab(nextIndex); } }这段逻辑看起来简单但实际项目中经常被漏掉导致删除后焦点丢失到body上键盘用户直接懵了。5. 那些年我踩过的选项卡坑5.1 用display:none隐藏面板导致的问题display: none是最直接的隐藏方式但它有一个副作用面板内的表单元素会失去焦点而且如果面板内有iframe或者视频隐藏时会被卸载。如果你的选项卡面板里有正在播放的视频或者用户填了一半的表单切换标签再切回来状态就丢了。解决方案是用visibility: hidden配合position: absolute来隐藏这样元素还在DOM里状态保留只是不可见。但这样做的问题是隐藏的面板仍然占据布局空间除非用absolute脱离文档流而且屏幕阅读器可能仍然能读到。所以需要配合aria-hiddentrue来告诉辅助技术忽略它。我的选择是默认用hidden属性display:none如果面板内有需要保留状态的内容再改用visibility方案。不要一上来就过度设计大多数选项卡面板就是静态内容display:none完全够用。5.2 过渡动画和hidden属性的冲突想给面板切换加淡入淡出动画直接对hidden属性做transition是不行的因为display不是可过渡属性。你需要用两步先移除hidden然后在下一帧添加一个class触发过渡过渡结束后再处理。[roletabpanel] { opacity: 0; transition: opacity 0.2s; } [roletabpanel]:not([hidden]) { opacity: 1; }但这样有个问题面板从hidden变成可见的瞬间opacity从0开始过渡但浏览器可能来不及渲染初始状态就直接跳到1了。稳妥的做法是用requestAnimationFrame或者强制重排来确保过渡生效。不过说实话选项卡加动画的收益很低除非产品明确要求我一般不加省得给自己找麻烦。5.3 嵌套选项卡的事件冒泡选项卡里面再套选项卡点击内层标签时事件会冒泡到外层tablist导致外层也响应。解决方案是在内层的事件处理里调用e.stopPropagation()或者在外层判断e.target是否属于当前tablist的直接子标签。我更推荐后者因为stopPropagation会阻断所有上层监听可能影响其他逻辑。判断target的方式更精准tablist.addEventListener(click, (e) { const tab e.target.closest([roletab]); if (!tab || tab.parentElement ! tablist) return; // 处理切换 });tab.parentElement ! tablist这个判断确保只处理直接子标签嵌套的标签不会被外层捕获。5.4 服务端渲染时的状态同步如果页面是服务端渲染的HTML里已经写好了初始状态JS加载后如果直接跑初始化逻辑可能会和HTML里的状态冲突。比如HTML里第一个标签是选中的但JS初始化时又跑了一遍activateTab(0)虽然结果一样但如果服务端渲染的选中项不是第一个就会闪一下。解决方案是JS初始化时先读取HTML里的aria-selectedtrue的标签以它为基准同步状态而不是硬编码索引0。function init() { const selectedTab tablist.querySelector([roletab][aria-selectedtrue]) || tabs[0]; const index tabs.indexOf(selectedTab); activateTab(index); }这样无论服务端渲染的初始状态是什么JS都能正确接管。6. 一套可以直接抄的完整实现把前面的所有逻辑整合起来就是一套可以直接用的选项卡组件。我把它写成一个IIFE避免污染全局作用域。(function() { const tablist document.querySelector([roletablist]); if (!tablist) return; const tabs [...tablist.querySelectorAll([roletab])]; function activateTab(index) { tabs.forEach((tab, i) { const selected i index; tab.setAttribute(aria-selected, selected); tab.tabIndex selected ? 0 : -1; const panel document.getElementById(tab.getAttribute(aria-controls)); if (panel) panel.hidden !selected; }); } tablist.addEventListener(click, (e) { const tab e.target.closest([roletab]); if (!tab || tab.parentElement ! tablist) return; activateTab(tabs.indexOf(tab)); }); tablist.addEventListener(keydown, (e) { const currentIndex tabs.indexOf(document.activeElement); if (currentIndex -1) return; let targetIndex null; if (e.key ArrowRight) targetIndex (currentIndex 1) % tabs.length; else if (e.key ArrowLeft) targetIndex (currentIndex - 1 tabs.length) % tabs.length; else if (e.key Home) targetIndex 0; else if (e.key End) targetIndex tabs.length - 1; if (targetIndex ! null) { e.preventDefault(); tabs[targetIndex].focus(); activateTab(targetIndex); } }); const initial tablist.querySelector([aria-selectedtrue]) || tabs[0]; activateTab(tabs.indexOf(initial)); })();这段代码不到40行覆盖了点击切换、方向键导航、Home/End键、焦点管理、初始状态同步。没有依赖任何库直接扔到页面里就能跑。CSS部分用前面给的样式就够如果想换主题色改aria-selectedtrue那条规则的color和border-color就行。想加动画在tabpanel上加transition但记得处理hidden和opacity的配合。我在实际项目里用这套结构做过配置面板、商品详情切换、后台多标签页稳定跑了两年多没出过问题。唯一需要根据场景调整的是懒加载逻辑和动态增删的id管理但核心的切换和键盘导航部分从来没动过。如果你正在写选项卡建议先把HTML骨架和CSS状态机搭好用键盘走一遍确认方向键、Home、End都能正常工作再往上加动画和懒加载。顺序反了的话后面每加一个功能都要回头改结构成本会翻倍。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑