✎ 修改左侧代码,右侧实时更新
🧪
前两天做一个课程详情页,最不起眼的地方反而卡了一下:FAQ。
设计稿看着很简单,白底、浅蓝卡片、点一下展开答案,再点一下收起。产品还补了一句:“别太硬,展开的时候顺一点。”
如果只要能开合,HTML 自带的 <details> 和 <summary> 两个标签就够了。但问题在于,它们默认是“啪”一下打开、“啪”一下关上,没有过渡。你想让它像抽屉一样慢慢展开,就得用一点 JS 接管点击,再自己算高度。
这段代码做的就是这件事:用原生 <details> 写一个 FAQ 手风琴,再用 JS 给展开和收起补动画。
先看 HTML。它没有用一堆 div 假装折叠面板,而是用了浏览器原生的 <details>。
你可以把 <details> 想成一个小抽屉,<summary> 是抽屉把手,下面的 .faq-content 是抽屉里的内容。
html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- 页面标题 -->
<title>Accordion 01</title>
<!-- 让移动端按设备宽度显示,不然手机上会像缩小版网页 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 提前连接 Google Fonts,减少字体加载等待 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- 引入 Poppins 字体 -->
<link
href="https://fonts.googleapis.com/css2?family=Poppins&display=swap"
rel="stylesheet"
type="text/css"
>
</head>
<body>
<!-- FAQ 外层容器,负责把多个问题竖着排开 -->
<div class="faq-container">
<!-- 一个 details 就是一条 FAQ -->
<details>
<!-- summary 是可点击的标题区域 -->
<summary>
<!-- 问题标题 -->
<span class="faq-title">
How long does the course take?
</span>
<!-- 右侧加号图标,展开后 JS 会换成减号 -->
<img
src="https://atheros-production-new.s3.eu-west-2.amazonaws.com/atheros-learning/courses/your-guide-to-becoming-frontend-developer/ui/accordion-01/assets/plus.svg"
class="expand-icon"
alt="Plus"
>
</summary>
<!-- 展开后显示的答案内容 -->
<div class="faq-content">
The video content takes more than 4.5 hours. ...
</div>
</details>
<details>
<summary>
<span class="faq-title">
Who teaches courses on Atheros Learning?
</span>
<img
src="https://atheros-production-new.s3.eu-west-2.amazonaws.com/atheros-learning/courses/your-guide-to-becoming-frontend-developer/ui/accordion-01/assets/plus.svg"
class="expand-icon"
alt="Plus"
>
</summary>
<div class="faq-content">
The authors of the courses are mostly ...
</div>
</details>
<details>
<summary>
<span class="faq-title">
How is the course different from other UX/UI design courses?
</span>
<img
src="https://atheros-production-new.s3.eu-west-2.amazonaws.com/atheros-learning/courses/your-guide-to-becoming-frontend-developer/ui/accordion-01/assets/plus.svg"
class="expand-icon"
alt="Plus"
>
</summary>
<div class="faq-content">
The key aspect is that this course provides a clear overview of the whole design process and principles...
</div>
</details>
</div>
</body>
</html>这里最值得记的是 <details> 和 <summary>。
正常情况下,你甚至不用写 JS:
html<details>
<summary>问题</summary>
<div>答案</div>
</details>浏览器自己就会帮你处理开合,还会自动维护一个 open 属性。
比如打开后,DOM 会变成这样:
html<details open>
...
</details>这很舒服。因为我们没有从零造一个折叠组件,而是先借用了浏览器原生能力。JS 后面做的,只是把“突然打开”改成“慢慢打开”。
接着看 CSS。它负责让这个原生抽屉变得像一张浅色 FAQ 卡片。
csshtml {
/* 基准字号,后面 1rem 就等于 16px */
font-size: 16px;
/* 使用前面引入的 Poppins 字体 */
font-family: "Poppins";
}
body {
/* Light Mode 的底色,干净白 */
background: white;
height: 100%;
}
/* 所有元素都用 border-box,宽高计算更直觉 */
* {
box-sizing: border-box;
}
.faq-container {
/* 让 FAQ 一条一条竖着排 */
display: flex;
flex-direction: column;
/* 每条之间隔 1rem */
gap: 1rem;
}.faq-container 很像一个文件夹架子,里面每一张卡片都竖着放。gap 就是卡片之间留出来的空隙,比给每个子元素写 margin-bottom 更干净。
cssdetails {
font-size: 1rem;
/* 左右自动居中 */
margin: 0 auto;
/* 宽度占满父容器,但不会超过 600px */
width: 100%;
max-width: 600px;
/* 浅蓝背景,符合 Light Mode 的轻感 */
background: #F6FAFF;
/* 圆角 */
border-radius: 0.5rem;
/* 给右侧图标 absolute 定位做参照 */
position: relative;
/* 默认边框 */
border: 1px solid #C3DEFF;
/* hover、边框变化时别太生硬 */
transition: all 0.3s ease-in-out;
}
details:hover {
/* 鼠标移上去,边框颜色更明显 */
border: 1px solid #A4A1FF;
}这里有个细节:details 设置了 position: relative,不是为了自己移动,而是给里面的 .expand-icon 用。
后面图标会写:
cssposition: absolute;
right: 1rem;那它就会以当前这张 FAQ 卡片为范围,贴到右侧,而不是跑到整个页面右边去。
csssummary {
/* 不让用户选中标题文字,点击体验更像按钮 */
user-select: none;
/* 鼠标变成小手,告诉用户这里能点 */
cursor: pointer;
font-weight: 600;
/* 标题和图标横向排列 */
display: flex;
/* 去掉 summary 默认的小三角 */
list-style: none;
padding: 1rem;
/* 垂直居中 */
align-items: center;
}
/* Chrome / Safari 里隐藏默认 details 小三角 */
summary::-webkit-details-marker {
display: none;
}
/* 点击后不显示默认 outline */
summary:focus {
outline: none;
}summary 默认会带一个小三角,像浏览器原装零件。这里已经有自定义的加号图标了,所以把默认标记藏掉。
不过 summary:focus { outline: none; } 这个地方我会稍微谨慎一点。视觉上确实干净,但键盘用户会看不出来焦点在哪里。真实项目里最好补一个自己的 focus 样式,比如:
csssummary:focus-visible {
outline: 2px solid #A4A1FF;
outline-offset: 2px;
}不是所有“默认样式”都该删。删了,就得补一个更好的。
csssummary:hover .faq-title {
opacity: 1;
}
summary:hover .expand-icon {
opacity: 1;
}
.faq-title {
color: #1C2035;
/* 留 90% 宽度,右边给图标腾地方 */
width: 90%;
/* 默认稍微淡一点 */
opacity: 0.65;
/* hover 时透明度变化更柔和 */
transition: all 250ms ease-in-out;
}
.faq-content {
color: #303651;
/* 上方少一点,左右和底部多一点 */
padding: 0.2rem 1rem 1rem 1rem;
font-weight: 300;
line-height: 1.5;
}标题默认 opacity: 0.65,鼠标移上去变成 1,这就是常见的 Light Mode 小交互。不是大红大紫地提醒你,而是轻轻亮一下。
css.expand-icon {
opacity: 0.65;
/* 图标不接收鼠标事件,点击会落到 summary 上 */
pointer-events: none;
/* 让图标固定在卡片右侧 */
position: absolute;
right: 1rem;
/* 图标变化时顺一点 */
transition: all 150ms ease-out;
}pointer-events: none 很关键。
否则你点到图标时,事件可能先落在 img 上。虽然大多数时候也能冒泡到 summary,但这里干脆让图标“不挡手”,用户点标题也好,点图标也好,本质上都是点 summary。
<details> 原生开合没问题,但它的高度是 auto。CSS 很难直接从 height: 0 动画到 height: auto。
就像你问一个抽屉:“你最后要拉多长?”
它说:“自动。”
动画最怕这个。动画需要明确的起点和终点,比如从 56px 到 180px。所以 JS 要做的事情就是:在打开或关闭之前,先量一下现在多高、目标多高,再把这两个数字交给浏览器做动画。
原代码用的是 Web Animations API,也就是这句:
jsthis.el.animate(...)它比手写 transition + setTimeout 更直接,能取消动画,也能监听动画结束。
先看整个类的初始化。
js// 这段代码用来给 details / summary 做展开收起动画
// 原思路来自 CSS-Tricks 的 details 元素动画文章,并做了一点修改
class Accordion {
constructor(el) {
// 当前这一条 details
this.el = el;
// details 里面的 summary,也就是可点击标题
this.summary = el.querySelector('summary');
// 展开后显示的内容区域
this.content = el.querySelector('.faq-content');
// summary 里的加号 / 减号图标
this.expandIcon = this.summary.querySelector('.expand-icon');
// 当前正在跑的动画对象
// 有它,就能在用户连续点击时取消上一次动画
this.animation = null;
// 标记:现在是不是正在关闭
this.isClosing = false;
// 标记:现在是不是正在展开
this.isExpanding = false;
// 点击 summary 时,不让浏览器自己开合,改成走我们自己的动画逻辑
this.summary.addEventListener('click', (e) => this.onClick(e));
}
}这段像是在给每个抽屉配一个管理员。
管理员手里记着几样东西:
this.el:是哪一个抽屉。this.summary:抽屉把手。this.content:抽屉里的内容。this.expandIcon:把手旁边的加号。this.animation:当前有没有动画在跑。this.isClosing / this.isExpanding:现在是正在关,还是正在开。为什么要有 isClosing 和 isExpanding?
因为用户不会等你动画播完再点。很多人会连续点两下、三下。如果没有这些状态,动画很容易打架:你这边还没展开完,那边又开始收起,最后高度卡在一个奇怪的数值。
点击时的分流在这里:
jsonClick(e) {
// 阻止 details 默认开合
// 不然浏览器自己开一次,我们 JS 又开一次,会乱
e.preventDefault();
// 动画过程中隐藏溢出内容
// 不然收起时文字可能露出来
this.el.style.overflow = 'hidden';
if (this.isClosing || !this.el.open) {
// 如果正在关闭,或者当前本来就是关着的,那就打开
this.open();
} else if (this.isExpanding || this.el.open) {
// 如果正在展开,或者当前已经打开了,那就收起
this.shrink();
}
}这里的核心是 e.preventDefault()。
正常点 <summary>,浏览器会自动切换 <details open>。但现在我们要自己控制动画,就不能让浏览器抢先一步。
否则会发生这种事:
txt你点 summary │ ├─ 浏览器:我先把 details 打开 │ └─ JS:我也准备算高度做动画
两个人同时抢方向盘,车肯定晃。
关闭的时候,代码是这样:
jsshrink() {
// 标记:正在关闭
this.isClosing = true;
// 起始高度:当前 details 的完整高度
// 比如标题 + 内容一共 180px
const startHeight = `${this.el.offsetHeight}px`;
// 结束高度:只剩 summary 的高度
// 比如只剩标题 56px
const endHeight = `${this.summary.offsetHeight}px`;
// 如果之前有动画还没结束,先取消
if (this.animation) {
this.animation.cancel();
}
// 从完整高度动画到标题高度
this.animation = this.el.animate({
height: [startHeight, endHeight]
}, {
duration: 400,
easing: 'ease-out'
});
// 动画正常结束
this.animation.onfinish = () => {
// 图标换回加号
this.expandIcon.setAttribute(
'src',
'https://atheros-production-new.s3.eu-west-2.amazonaws.com/atheros-learning/courses/your-guide-to-becoming-frontend-developer/ui/accordion-01/assets/plus.svg'
);
// 最终设置为关闭状态
return this.onAnimationFinish(false);
};
// 动画被取消,比如用户中途又点了一次
this.animation.oncancel = () => {
// 图标也先换回加号
this.expandIcon.setAttribute(
'src',
'https://atheros-production-new.s3.eu-west-2.amazonaws.com/atheros-learning/courses/your-guide-to-becoming-frontend-developer/ui/accordion-01/assets/plus.svg'
);
// 取消“正在关闭”的标记
return this.isClosing = false;
};
}关闭的逻辑很像把抽屉推回去。
先量现在抽屉拉出来多长:
jsthis.el.offsetHeight再量只露出把手时多长:
jsthis.summary.offsetHeight然后让高度从前者变到后者。
打开稍微绕一点:
jsopen() {
// 先把当前高度固定住
// 不然一设置 open,内容马上出现,高度会突然变
this.el.style.height = `${this.el.offsetHeight}px`;
// 先让 details 进入 open 状态
// 因为不 open 的话,里面内容高度量不到
this.el.open = true;
// 等浏览器把 open 状态应用到页面后,再开始算展开动画
window.requestAnimationFrame(() => this.expand());
}这里是整段 JS 里最值得品的地方。
你要展开,就得知道“展开后的高度”。但 <details> 关着的时候,内容是不可见的,很多高度不好算。
所以它先做三步:
txt1. 先把当前高度固定住 2. 设置 details.open = true,让内容出现在布局里 3. 下一帧再计算完整高度,开始动画
requestAnimationFrame 可以理解成:“等浏览器把上一件事处理完,下一帧我再来。”
不然你刚设置 open = true,马上读高度,浏览器可能还没来得及更新布局,读到的数据就不稳。
真正展开在这里:
jsexpand() {
// 标记:正在展开
this.isExpanding = true;
// 起始高度:刚打开时固定住的高度
const startHeight = `${this.el.offsetHeight}px`;
// 结束高度:summary 高度 + 内容高度
const endHeight = `${this.summary.offsetHeight + this.content.offsetHeight}px`;
// 如果有旧动画,先取消
if (this.animation) {
this.animation.cancel();
}
// 从标题高度动画到完整高度
this.animation = this.el.animate({
height: [startHeight, endHeight]
}, {
duration: 350,
easing: 'ease-out'
});
// 动画结束后,把图标换成减号
this.animation.onfinish = () => {
this.expandIcon.setAttribute(
'src',
'https://atheros-production-new.s3.eu-west-2.amazonaws.com/atheros-learning/courses/your-guide-to-becoming-frontend-developer/ui/accordion-01/assets/minus.svg'
);
return this.onAnimationFinish(true);
};
// 动画取消时,也处理一下状态和图标
this.animation.oncancel = () => {
this.expandIcon.setAttribute(
'src',
'https://atheros-production-new.s3.eu-west-2.amazonaws.com/atheros-learning/courses/your-guide-to-becoming-frontend-developer/ui/accordion-01/assets/minus.svg'
);
return this.isExpanding = false;
};
}最后统一收尾:
jsonAnimationFinish(open) {
// 最终设置 details 是打开还是关闭
this.el.open = open;
// 清空动画对象
this.animation = null;
// 两个过程标记都复位
this.isClosing = false;
this.isExpanding = false;
// 清掉临时写上的 height 和 overflow
// 这样内容变多变少时,details 能回到自然高度
this.el.style.height = this.el.style.overflow = '';
}为什么动画结束后要清掉 height?
因为动画期间我们把高度写死了。比如展开到 180px。如果后面内容因为响应式换行变成了 220px,写死高度就会出问题。
所以动画完成后,临时高度必须撤掉,让它回到自然布局。
最后这段,是给页面上所有 <details> 都挂上动画能力:
jsdocument.querySelectorAll('details').forEach((el) => {
new Accordion(el);
});这句很像巡场:页面里每找到一个 FAQ 抽屉,就给它配一个 Accordion 管理员。
整个流程画出来更清楚:
txt用户点击 summary │ ▼ 阻止浏览器默认开合 preventDefault() │ ▼ 判断当前状态 │ ├──────────── details 关着 / 正在关闭 │ │ │ ▼ │ open() │ │ │ 固定当前高度 → 设置 open=true │ │ │ ▼ │ requestAnimationFrame 等一帧 │ │ │ ▼ │ expand() │ │ │ 计算:标题高度 + 内容高度 │ │ │ ▼ │ animate(height: 小 → 大) │ │ │ ▼ │ 图标换 minus,清理临时样式 │ └──────────── details 开着 / 正在展开 │ ▼ shrink() │ 计算:当前总高度 → 标题高度 │ ▼ animate(height: 大 → 小) │ ▼ 图标换 plus,设置 open=false
这个流转最绕的地方只有一个:打开前必须先 open = true,否则你量不到展开后的内容高度。
原版能跑,但放进国内项目,我一般会改几处。
第一,图标别依赖远程 SVG 地址。FAQ 这种基础组件,没必要为了一个加号、减号去请求外部资源。网络慢、资源挂了、跨域策略变了,右侧图标都可能出问题。
第二,不建议直接扫页面上所有 details。真实项目里页面可能还有别的 <details>,比如文档目录、筛选项、调试信息。最好用一个明确的选择器,比如 [data-accordion]。
第三,动画要尊重用户的系统设置。如果用户开启了“减少动态效果”,我们就别强行动画。
先放 TS 版。
tstype AccordionOptions = {
duration?: number;
easing?: string;
};
class FaqAccordion {
private el: HTMLDetailsElement;
private summary: HTMLElement;
private content: HTMLElement;
private animation: Animation | null = null;
private isClosing = false;
private isExpanding = false;
private duration: number;
private easing: string;
private reduceMotion: boolean;
constructor(el: HTMLDetailsElement, options: AccordionOptions = {}) {
this.el = el;
const summary = el.querySelector<HTMLElement>('summary');
const content = el.querySelector<HTMLElement>('[data-accordion-content]');
if (!summary || !content) {
throw new Error('FaqAccordion 需要 summary 和 [data-accordion-content]');
}
this.summary = summary;
this.content = content;
this.duration = options.duration ?? 300;
this.easing = options.easing ?? 'ease-out';
this.reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this.summary.addEventListener('click', this.handleClick);
}
private handleClick = (event: MouseEvent) => {
event.preventDefault();
if (this.reduceMotion) {
this.el.open = !this.el.open;
return;
}
this.el.style.overflow = 'hidden';
if (this.isClosing || !this.el.open) {
this.open();
return;
}
if (this.isExpanding || this.el.open) {
this.close();
}
};
private close() {
this.isClosing = true;
const startHeight = `${this.el.offsetHeight}px`;
const endHeight = `${this.summary.offsetHeight}px`;
this.animation?.cancel();
this.animation = this.el.animate(
{ height: [startHeight, endHeight] },
{
duration: this.duration,
easing: this.easing,
}
);
this.animation.onfinish = () => this.finish(false);
this.animation.oncancel = () => {
this.isClosing = false;
};
}
private open() {
this.el.style.height = `${this.el.offsetHeight}px`;
this.el.open = true;
window.requestAnimationFrame(() => this.expand());
}
private expand() {
this.isExpanding = true;
const startHeight = `${this.el.offsetHeight}px`;
const endHeight = `${this.summary.offsetHeight + this.content.offsetHeight}px`;
this.animation?.cancel();
this.animation = this.el.animate(
{ height: [startHeight, endHeight] },
{
duration: this.duration,
easing: this.easing,
}
);
this.animation.onfinish = () => this.finish(true);
this.animation.oncancel = () => {
this.isExpanding = false;
};
}
private finish(open: boolean) {
this.el.open = open;
this.animation = null;
this.isClosing = false;
this.isExpanding = false;
this.el.style.height = '';
this.el.style.overflow = '';
}
destroy() {
this.summary.removeEventListener('click', this.handleClick);
this.animation?.cancel();
}
}
document.querySelectorAll<HTMLDetailsElement>('[data-accordion]').forEach((el) => {
new FaqAccordion(el);
});HTML 对应可以写成这样,图标直接交给 CSS,不用图片:
html<div class="faq-container">
<details class="faq-item" data-accordion>
<summary class="faq-summary">
<span class="faq-title">课程一般多久能学完?</span>
<span class="faq-icon" aria-hidden="true"></span>
</summary>
<div class="faq-content" data-accordion-content>
如果每天学习 30 到 40 分钟,大多数同学一到两周可以完整过一遍。
</div>
</details>
<details class="faq-item" data-accordion>
<summary class="faq-summary">
<span class="faq-title">学完会有证书吗?</span>
<span class="faq-icon" aria-hidden="true"></span>
</summary>
<div class="faq-content" data-accordion-content>
完成课程任务和测验后,可以在个人中心下载结课证明。
</div>
</details>
</div>CSS 里用伪元素画加号和减号:
css.faq-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.faq-item {
max-width: 600px;
width: 100%;
margin: 0 auto;
border: 1px solid #c3deff;
border-radius: 12px;
background: #f6faff;
}
.faq-summary {
position: relative;
display: flex;
align-items: center;
min-height: 56px;
padding: 1rem 3rem 1rem 1rem;
cursor: pointer;
list-style: none;
user-select: none;
}
.faq-summary::-webkit-details-marker {
display: none;
}
.faq-summary:focus-visible {
outline: 2px solid #a4a1ff;
outline-offset: 3px;
}
.faq-title {
color: #1c2035;
font-weight: 600;
}
.faq-content {
padding: 0 1rem 1rem;
color: #303651;
line-height: 1.6;
}
.faq-icon {
position: absolute;
right: 1rem;
width: 16px;
height: 16px;
}
.faq-icon::before,
.faq-icon::after {
content: "";
position: absolute;
inset: 7px 0 auto 0;
height: 2px;
border-radius: 999px;
background: #303651;
transition: transform 200ms ease;
}
.faq-icon::after {
transform: rotate(90deg);
}
/* details 打开时,竖线转平,加号变减号 */
.faq-item[open] .faq-icon::after {
transform: rotate(0deg);
}如果项目不用 TS,JS 版就是这样:
jsclass FaqAccordion {
constructor(el, options = {}) {
this.el = el;
this.summary = el.querySelector('summary');
this.content = el.querySelector('[data-accordion-content]');
if (!this.summary || !this.content) {
throw new Error('FaqAccordion 需要 summary 和 [data-accordion-content]');
}
this.animation = null;
this.isClosing = false;
this.isExpanding = false;
this.duration = options.duration ?? 300;
this.easing = options.easing ?? 'ease-out';
this.reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this.handleClick = this.handleClick.bind(this);
this.summary.addEventListener('click', this.handleClick);
}
handleClick(event) {
event.preventDefault();
if (this.reduceMotion) {
this.el.open = !this.el.open;
return;
}
this.el.style.overflow = 'hidden';
if (this.isClosing || !this.el.open) {
this.open();
return;
}
if (this.isExpanding || this.el.open) {
this.close();
}
}
close() {
this.isClosing = true;
const startHeight = `${this.el.offsetHeight}px`;
const endHeight = `${this.summary.offsetHeight}px`;
if (this.animation) {
this.animation.cancel();
}
this.animation = this.el.animate(
{ height: [startHeight, endHeight] },
{
duration: this.duration,
easing: this.easing,
}
);
this.animation.onfinish = () => this.finish(false);
this.animation.oncancel = () => {
this.isClosing = false;
};
}
open() {
this.el.style.height = `${this.el.offsetHeight}px`;
this.el.open = true;
window.requestAnimationFrame(() => this.expand());
}
expand() {
this.isExpanding = true;
const startHeight = `${this.el.offsetHeight}px`;
const endHeight = `${this.summary.offsetHeight + this.content.offsetHeight}px`;
if (this.animation) {
this.animation.cancel();
}
this.animation = this.el.animate(
{ height: [startHeight, endHeight] },
{
duration: this.duration,
easing: this.easing,
}
);
this.animation.onfinish = () => this.finish(true);
this.animation.oncancel = () => {
this.isExpanding = false;
};
}
finish(open) {
this.el.open = open;
this.animation = null;
this.isClosing = false;
this.isExpanding = false;
this.el.style.height = '';
this.el.style.overflow = '';
}
destroy() {
this.summary.removeEventListener('click', this.handleClick);
if (this.animation) {
this.animation.cancel();
}
}
}
document.querySelectorAll('[data-accordion]').forEach((el) => {
new FaqAccordion(el);
});这个版本没有远程图标,选择器也更安全。以后页面里有别的 <details>,不会被误伤。
这套写法适合“看起来简单,但交互要顺”的地方,比如课程详情页、帮助中心、价格页 FAQ。
但如果只是后台里一个临时说明,或者内容特别短,其实直接用原生 <details> 就够了:
html<details>
<summary>为什么提交失败?</summary>
<p>请检查必填项是否完整。</p>
</details>不用动画,不用类,不用状态标记。能用浏览器原生能力解决,就别急着上强度。
还有一种情况也要小心:如果页面里有几十上百条 FAQ,而且内容经常变化、里面还有图片懒加载,手动算高度就会更麻烦。图片加载完成后高度变了,动画结束时的高度可能不准。这时候要么简化交互,要么在内容加载后重新处理布局。
另外,Web Animations API 现在主流浏览器基本能用,但如果你的项目要兼容很老的浏览器,就得查一下兼容性,或者退回 CSS transition 的方案。
说到底,这段代码最值得学的不是“怎么写一个 FAQ 组件”,而是一个思路:
浏览器原生能做的,先用原生。
原生体验不够顺,再用 JS 补一层。
补的时候别抢默认行为,要接管就接管完整:阻止默认点击、自己算高度、动画结束后清理现场。
你项目里的 FAQ 是怎么做的?直接 <details>,自己写动画,还是上组件库?如果你遇到过“展开高度不准”“连点几下卡住”“图标状态反了”这种坑,可以把场景丢出来,下次可以专门拆一版更稳的手风琴组件。