
JS 特效:制作一个点击展开的折叠菜单
折叠菜单把暂时不需要的信息收起,用户点击按钮后再展开。它常见于常见问题列表、设置面板、商品详情和侧边导航。实现时,真正重要的不只是“切换一个显示样式”,还要保证按钮语义、键盘操作、辅助技术可读的状态,以及展开内容与按钮之间的关联。
本文使用原生 HTML、CSS 和 JavaScript 实现一个可直接运行的示例:每个按钮独立控制一个内容面板,点击后展开,再次点击收起。示例不依赖第三方库,也不通过拼接 HTML 字符串生成内容。
一、先明确折叠菜单的行为
开始写代码前,应先确定交互规则。本文采用以下约定:
- 点击某个按钮,只切换它对应的面板。
- 多个面板可以同时保持展开;这更接近常见问题列表的行为。
- 页面初始状态为收起,按钮和面板的状态一致。
- 按钮使用原生
<button>,因此可通过键盘聚焦,并可用 Enter 或空格触发。 - 面板收起时使用 HTML 的
hidden属性,不只是把内容移出屏幕视觉区域。
如果产品要求同一时间只能展开一项,就需要在打开当前面板时同时关闭其他面板。那是另一种交互规则,后文会说明如何调整。
二、可运行的完整示例
把下面代码保存为 accordion.html,然后用浏览器打开即可。代码中的 HTML、CSS 和 JavaScript 都在同一文件中,便于理解;实际项目也可以将样式和脚本拆分到独立文件。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>折叠菜单示例</title>
<style>
* {
box-sizing: border-box;
}
body {
max-width: 720px;
margin: 48px auto;
padding: 0 20px;
color: #202124;
font-family: system-ui, sans-serif;
line-height: 1.6;
}
.accordion {
border-top: 1px solid #d8dce2;
}
.accordion__item {
border-bottom: 1px solid #d8dce2;
}
.accordion__trigger {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 4px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-weight: 650;
text-align: left;
cursor: pointer;
}
.accordion__trigger:hover {
color: #135caa;
}
.accordion__trigger:focus-visible {
outline: 3px solid #4b8ee8;
outline-offset: 3px;
border-radius: 4px;
}
.accordion__icon {
flex: 0 0 auto;
transition: transform 180ms ease;
}
.accordion__trigger[aria-expanded="true"] .accordion__icon {
transform: rotate(180deg);
}
.accordion__panel {
padding: 0 4px 16px;
color: #454b54;
}
.accordion__panel p {
margin: 0;
}
@media (prefers-reduced-motion: reduce) {
.accordion__icon {
transition: none;
}
}
</style>
</head>
<body>
<h1>常见问题</h1>
<div class="accordion">
<section class="accordion__item">
<h2>
<button
class="accordion__trigger"
type="button"
id="question-1"
aria-expanded="false"
aria-controls="answer-1">
<span>折叠菜单是什么?</span>
<span class="accordion__icon" aria-hidden="true">⌄</span>
</button>
</h2>
<div
class="accordion__panel"
id="answer-1"
role="region"
aria-labelledby="question-1"
hidden>
<p>折叠菜单允许用户按需显示或隐藏一段相关内容,常用于常见问题、设置项和详情说明。</p>
</div>
</section>
<section class="accordion__item">
<h2>
<button
class="accordion__trigger"
type="button"
id="question-2"
aria-expanded="false"
aria-controls="answer-2">
<span>这个示例需要安装依赖吗?</span>
<span class="accordion__icon" aria-hidden="true">⌄</span>
</button>
</h2>
<div
class="accordion__panel"
id="answer-2"
role="region"
aria-labelledby="question-2"
hidden>
<p>不需要。示例只使用浏览器内置的 HTML、CSS 和 JavaScript 功能。</p>
</div>
</section>
<section class="accordion__item">
<h2>
<button
class="accordion__trigger"
type="button"
id="question-3"
aria-expanded="false"
aria-controls="answer-3">
<span>可以同时展开多个答案吗?</span>
<span class="accordion__icon" aria-hidden="true">⌄</span>
</button>
</h2>
<div
class="accordion__panel"
id="answer-3"
role="region"
aria-labelledby="question-3"
hidden>
<p>可以。当前示例中,每个按钮只切换与自己关联的面板,不会关闭其他面板。</p>
</div>
</section>
</div>
<script>
document.querySelectorAll(".accordion__trigger").forEach((button) => {
button.addEventListener("click", () => {
const panelId = button.getAttribute("aria-controls");
const panel = document.getElementById(panelId);
// 标记错误的关联,避免继续操作空对象。
if (!panel) {
console.warn("找不到折叠面板:", panelId);
return;
}
const willExpand = button.getAttribute("aria-expanded") !== "true";
button.setAttribute("aria-expanded", String(willExpand));
panel.hidden = !willExpand;
});
});
</script>
</body>
</html>
三、代码是如何工作的
1. 用按钮表达操作
展开和收起是用户执行的操作,因此这里使用 <button type="button">,而不是用没有语义的 <div> 模拟按钮。原生按钮默认支持键盘焦点和键盘激活,也会向辅助技术表明它是一个可操作控件。
type="button" 也有实际作用:如果这个组件以后被放进表单,未明确设置类型的按钮在部分上下文中可能按提交按钮处理。指定类型可以避免点击菜单时意外提交表单。
2. 用两个属性建立状态关联
每个按钮有一个唯一的 id,并通过 aria-controls 指向对应面板的 id。例如,按钮的 aria-controls="answer-1" 应与面板的 id="answer-1" 完全一致。按钮上的 aria-expanded 则告诉辅助技术该面板当前是否展开,值应为字符串 "true" 或 "false"。
面板使用 aria-labelledby 指向按钮,使面板具有来自按钮文字的可读名称。示例还给面板添加了 role="region",让它作为一个可识别区域呈现。对于只有少量文字的简单列表,不一定要给每个面板都添加 region;页面上区域过多时,反而可能增加辅助技术用户浏览时的负担。是否使用这个角色,应结合内容结构决定。
3. 用 hidden 控制内容可见性
HTML 的 hidden 属性表示元素当前不应呈现。JavaScript 设置 panel.hidden = true 会将面板收起,设置为 false 则会显示面板。代码同时更新 aria-expanded,确保视觉状态与语义状态一致。
不建议只修改按钮文字,或只通过 CSS 改变面板外观而不更新展开状态。用户可能看到内容已展开,但辅助技术仍读到“已收起”;也可能按钮显示“展开”,内容实际上却处于打开状态。
4. 点击时反转当前状态
事件处理器先从按钮读取 aria-controls,再查找相应面板。随后根据 aria-expanded 算出新的状态,并同时更新按钮属性与面板的 hidden 属性。这样状态只有一个明确来源,不必维护另一份容易失同步的变量。
这里使用 getElementById() 查找面板。如果关联目标不存在,方法会返回 null。代码检查了这一情况并提前返回,避免对空对象设置属性时产生运行时错误。实际项目中,如果组件由模板生成,也应保证所有控件和面板的关联完整。
四、如何改成单项展开
常见问题列表通常允许同时展开多个答案,但某些菜单或设置面板会要求一次只打开一项。要实现这种规则,可以在点击处理器中,先关闭同一组内其他已展开的按钮和面板,再打开当前项。重要的是要限定到当前组件,不要无差别关闭页面上其他折叠菜单。
下面展示处理思路,可替换前面脚本中计算状态和更新状态的部分。此片段假设按钮处在同一个 .accordion 容器内:
const root = button.closest(".accordion");
root.querySelectorAll(".accordion__trigger[aria-expanded='true']")
.forEach((otherButton) => {
if (otherButton === button) return;
const otherPanelId = otherButton.getAttribute("aria-controls");
const otherPanel = document.getElementById(otherPanelId);
otherButton.setAttribute("aria-expanded", "false");
if (otherPanel) {
otherPanel.hidden = true;
}
});
const willExpand = button.getAttribute("aria-expanded") !== "true";
button.setAttribute("aria-expanded", String(willExpand));
panel.hidden = !willExpand;
如果产品要求“打开一项后不能通过再次点击收起”,则需要再明确按钮的状态规则,并调整处理器;不能仅凭“手风琴”这个名称假设用户需要哪种行为。不同项目的设计约定可能不同,最好先写成可验证的交互要求。
五、边界与实现选择
默认收起还是默认展开
示例在 HTML 中写了 aria-expanded="false" 和 hidden,代表页面加载时收起。若要默认展开某一项,需要同时把对应按钮改为 aria-expanded="true",并移除该面板上的 hidden。只改其中一处会造成状态不一致。初始化状态最好直接写在 HTML 中,而不是等脚本执行后再猜测页面状态。
JavaScript 未运行时的表现
当前示例的面板初始带有 hidden,而展开能力由 JavaScript 提供。如果脚本因网络错误、浏览器设置或其他原因没有执行,面板会一直收起。对于帮助说明、重要政策或必须可访问的内容,可以选择让内容在无脚本时仍能阅读,再由成功初始化的脚本接管交互;这需要额外设计初始状态和初始化时机,避免脚本未运行与已运行时的呈现不一致。
动画与 hidden
hidden 会直接隐藏面板,不能简单地同时期待它执行高度收缩动画。若必须做高度过渡,需要更复杂的状态管理:展开时先让元素参与布局,再测量内容高度并设置过渡;收起时先动画到零,再在动画结束后隐藏。还要处理窗口尺寸变化、内容动态更新和用户偏好减少动态效果等情况。单纯给 hidden 元素添加 transition 通常不会得到预期效果。若动画不是产品必需,优先保留简单、可靠的显隐逻辑。
示例只旋转装饰性箭头,并通过 prefers-reduced-motion 在用户偏好减少动态效果时关闭旋转过渡。箭头带有 aria-hidden="true",避免把纯装饰字符重复读给屏幕阅读器。
重复使用组件时的 ID
HTML 文档中的 id 应保持唯一。如果页面上复制了多组菜单,却仍然重复使用 question-1、answer-1 等 ID,查找结果可能落到错误元素,aria-controls 也会指向不明确的目标。多实例组件应为每组生成唯一 ID,或在组件容器内使用有作用域的查找方式,并确保 ARIA 引用与实际元素相符。
六、常见错误与排查方法
- 点击后内容没有变化:检查脚本是否加载、浏览器控制台是否有错误,以及按钮的
aria-controls是否对应真实面板 ID。 - 所有按钮都打开同一块内容:通常是多个面板使用了相同 ID,或多个按钮错误地指向同一个面板。逐个核对按钮和面板的对应关系。
- 收起后仍能通过 Tab 聚焦面板里的链接:检查收起时是否确实给面板设置了
hidden。仅把高度设为零或将文字颜色设为透明,并不会可靠地从键盘顺序和辅助技术呈现中移除内容。 - 点击按钮导致页面刷新或表单提交:确认按钮写了
type="button",特别是组件位于<form>内时。 - 状态读起来不对:打开开发者工具检查按钮的
aria-expanded与面板的hidden是否匹配;展开时前者应是"true"、后者应不存在或为false。 - 鼠标能用,键盘不好用:优先使用原生按钮,不要移除焦点轮廓;用 Tab 移动到按钮,再用 Enter 和空格分别测试。
- 动画看起来卡顿:不要同时用
display: none(或hidden)和高度动画做未经协调的切换。先明确动画需要,再实现状态过渡。
七、安全注意事项
本例切换的是页面中已经写好的面板,没有把用户输入拼接成 HTML,因此没有使用 innerHTML。如果面板内容来自表单输入、接口返回值或其他不可信来源,不要把未经处理的数据直接写入 innerHTML;它可能被浏览器解析为 HTML,并在特定条件下造成跨站脚本攻击(XSS)。只显示纯文本时,应使用 textContent,并根据数据来源和用途进行适当校验。需要渲染允许的富文本时,应使用经过维护的 HTML 清理方案,并按项目的安全策略配置。
折叠菜单本身不是安全边界。把敏感内容设为 hidden 只能改变页面显示方式,不能保护已经发送到浏览器的数据。只要内容随页面源码或接口响应到达用户设备,就不能依靠“收起来”限制用户读取;真正的访问控制必须由服务器端验证。
示例中的 aria-controls、aria-expanded 和 hidden 也不是对参数长度或输入内容的安全限制。它们分别用于关联控件、表达状态和控制呈现。若把外部传入的 ID 用于查找元素,应确保其来自可信的组件结构或进行合适的约束,且不要把它当作授权凭证。
八、发布前检查清单
- 每个按钮都能准确控制对应面板,ID 不重复。
- 按钮使用原生语义,类型明确为
button。 - 初始状态和点击后的状态一致更新,包括
aria-expanded与hidden。 - 键盘用户能够通过 Tab 找到按钮,并用 Enter 或空格切换。
- 焦点轮廓清晰可见,装饰性图标不会造成无意义的重复朗读。
- 根据产品需求明确:可否同时展开多项,是否需要默认展开,以及无脚本时如何呈现内容。
- 没有把折叠隐藏误当作访问控制,也没有将不可信内容直接插入 HTML。
折叠菜单的基础实现并不复杂,但可靠的实现依赖于状态、语义和内容之间保持一致。先用原生按钮、唯一的控件关联和 hidden 完成明确的展开与收起,再按实际需求增加单项展开、动画或动态内容处理,比一开始堆叠复杂逻辑更容易维护。









