平滑展开收起笔记
约 1513 字大约 5 分钟
2025-12-26
使用 JS 计算高度
最真实、最通用,强烈推荐
核心思路:
- 展开时:把
height从0px动画到scrollHeight px - 收起时:把
height从当前高度动画到0px
<button id="toggle">展开/收起</button>
<div class="dropdown" id="dropdown">
<div class="content">
<p>这里是内容</p>
<p>可以很多很多行</p>
<p>高度不固定</p>
</div>
</div>.dropdown {
height: 0;
overflow: hidden;
transition: height 0.3s ease;
}const dropdown = document.getElementById("dropdown");
const btn = document.getElementById("toggle");
let open = false;
btn.onclick = () => {
open = !open;
if (open) {
// 先设置成真实高度,让它有动画目标
dropdown.style.height = dropdown.scrollHeight + "px";
// 动画完之后把 height 设成 auto,避免内容变化被截断
dropdown.addEventListener("transitionend", function handler() {
dropdown.style.height = "auto";
dropdown.removeEventListener("transitionend", handler);
});
} else {
// 收起:如果是 auto,先固定成具体高度再收起
dropdown.style.height = dropdown.scrollHeight + "px";
// 强制触发 reflow,让浏览器知道 height 变了
dropdown.offsetHeight;
dropdown.style.height = "0px";
}
};优点:高度自适应、动画自然、内容再多都能平滑
注意点:要做一次 auto 与 px 的切换
(height: 0 → scrollHeight) 如果内容很长,用固定 0.3s 的过渡会怪
CSS transition 本身就支持动画曲线:
transition: height 0.3s cubic-bezier(.4,0,.2,1);或者更自然一点的(展开更柔和):
transition: height 0.3s cubic-bezier(.33, 1, .68, 1);可以直接用 ease-in-out,但一般不如自定义贝塞尔顺滑。
更重要的是:根据高度动态调整 duration(解决“长列表怪”)
高度越大,动画越长,但要有上限、下限
这样长内容不会太快、短内容不会太慢。
推荐公式(经验值)
duration = clamp(min, height / speed, max)
speed 推荐 1.5px/ms ~ 2.5px/ms
比如:
- 高度 100px → 150ms
- 高度 500px → 300ms
- 高度 1200px → 450ms(上限)
方案 1(最简单):动态设置 transition-duration
CSS(只放 easing)
.dropdown {
height: 0;
overflow: hidden;
transition-property: height;
transition-timing-function: cubic-bezier(.33, 1, .68, 1);
}JS(计算 height + duration)
const dropdown = document.getElementById("dropdown");
const btn = document.getElementById("toggle");
let open = false;
function getDuration(height) {
// height(px) / speed(px/ms)
const speed = 2; // 数字越小越慢
const min = 150;
const max = 450;
return Math.min(max, Math.max(min, height / speed));
}
btn.onclick = () => {
open = !open;
if (open) {
const h = dropdown.scrollHeight;
const duration = getDuration(h);
dropdown.style.transitionDuration = duration + "ms";
dropdown.style.height = h + "px";
dropdown.addEventListener("transitionend", function handler() {
dropdown.style.height = "auto";
dropdown.removeEventListener("transitionend", handler);
});
} else {
const h = dropdown.scrollHeight;
const duration = getDuration(h);
dropdown.style.transitionDuration = duration + "ms";
dropdown.style.height = h + "px";
dropdown.offsetHeight; // force reflow
dropdown.style.height = "0px";
}
};这就是“内容越长动画越自然”的版本
再更进阶一点:让展开/收起都有不同的手感
通常:
- 展开:稍慢、柔和
- 收起:稍快、干脆
可以加两个不同 easing:
const easeOpen = "cubic-bezier(.33, 1, .68, 1)"; // easeOut-ish
const easeClose = "cubic-bezier(.32, 0, .67, 0)"; // easeIn-ish然后在 JS 里切换:
dropdown.style.transitionTimingFunction = open ? easeOpen : easeClose;长列表还有一个“视觉问题”:展开太长会像抽屉拉开一整块,很难看
这时候你可以做一种 UX 更高级的策略:超长内容改成「最多展开 X px,再内部滚动」
const MAX_HEIGHT = 320;
const h = Math.min(dropdown.scrollHeight, MAX_HEIGHT);
dropdown.style.height = h + "px";
dropdown.style.overflowY = dropdown.scrollHeight > MAX_HEIGHT ? "auto" : "hidden";这样动画会稳定、用户也不会被“拉很长”吓到。
完整 Demo
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>平滑下拉长列表 Demo</title>
<style>
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
padding: 40px;
background: #f7f7fb;
color: #222;
}
.card {
width: 420px;
border-radius: 14px;
background: #fff;
box-shadow: 0 8px 30px rgba(0,0,0,.08);
padding: 18px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
}
.title {
font-size: 16px;
font-weight: 700;
}
.btn {
border: none;
background: #111;
color: #fff;
padding: 10px 14px;
border-radius: 10px;
cursor: pointer;
font-weight: 600;
transition: transform 0.15s ease;
}
.btn:active {
transform: scale(0.96);
}
/* 核心:高度动画容器 */
.dropdown {
height: 0;
overflow: hidden;
transition-property: height;
margin-top: 12px;
border-radius: 12px;
background: #fafafa;
border: 1px solid #eee;
}
/* 内部内容区域 */
.content {
padding: 10px;
}
.list {
padding: 0;
margin: 0;
list-style: none;
}
.item {
padding: 10px 12px;
border-radius: 10px;
background: #fff;
border: 1px solid #eee;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
}
.tag {
font-size: 12px;
padding: 4px 8px;
border-radius: 999px;
background: #f0f0ff;
border: 1px solid #dedcff;
color: #3b33cc;
font-weight: 700;
}
.tips {
margin-top: 16px;
font-size: 13px;
color: #666;
line-height: 1.6;
}
.tips code {
background: #fff;
border: 1px solid #eee;
border-radius: 6px;
padding: 1px 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
</style>
</head>
<body>
<div class="card">
<div class="header">
<div class="title">长列表平滑下拉(带动态动画时间)</div>
<button class="btn" id="toggleBtn">展开</button>
</div>
<div class="dropdown" id="dropdown">
<div class="content">
<ul class="list" id="list"></ul>
</div>
</div>
<div class="tips">
动画从 <code>0 → scrollHeight</code> 平滑展开<br/>
根据内容高度动态计算 <code>duration</code>,长内容不会“啪一下”<br/>
超过最大高度后内部滚动(避免展开过长)<br/>
你可以修改 JS 里的 <code>COUNT</code> 和 <code>MAX_HEIGHT</code> 看效果。
</div>
</div>
<script>
const dropdown = document.getElementById("dropdown");
const btn = document.getElementById("toggleBtn");
const list = document.getElementById("list");
// ======== 你可以调这里 =========
const COUNT = 60; // 列表条数(越大越长)
const MAX_HEIGHT = 320; // 最大展开高度(px)
// ==============================
// 填充长列表
for (let i = 1; i <= COUNT; i++) {
const li = document.createElement("li");
li.className = "item";
li.innerHTML = `
<span>选项 Item ${i}</span>
<span class="tag">${i % 3 === 0 ? "HOT" : "NEW"}</span>
`;
list.appendChild(li);
}
let open = false;
// 动态计算动画时长:高度越高越慢,但限制上下限
function getDuration(height) {
const speed = 2; // px/ms (越小越慢)
const min = 160; // 最短 ms
const max = 520; // 最长 ms
return Math.min(max, Math.max(min, height / speed));
}
// 两个不同的 easing:展开柔和、收起干脆
const easeOpen = "cubic-bezier(.33, 1, .68, 1)";
const easeClose = "cubic-bezier(.32, 0, .67, 0)";
btn.addEventListener("click", () => {
open = !open;
btn.textContent = open ? "收起" : "展开";
// scrollHeight 是内容真实高度
const fullHeight = dropdown.scrollHeight;
// 如果内容很长,限制展开高度,内部滚动
const targetHeight = Math.min(fullHeight, MAX_HEIGHT);
// 根据高度计算 duration
const duration = getDuration(targetHeight);
dropdown.style.transitionTimingFunction = open ? easeOpen : easeClose;
dropdown.style.transitionDuration = duration + "ms";
if (open) {
// 展开:height 0 -> targetHeight
dropdown.style.overflowY = fullHeight > MAX_HEIGHT ? "auto" : "hidden";
dropdown.style.height = targetHeight + "px";
} else {
// 收起:先固定当前高度(防止从 auto 收起没动画)
dropdown.style.height = dropdown.offsetHeight + "px";
dropdown.offsetHeight; // 强制触发 reflow
dropdown.style.overflowY = "hidden";
dropdown.style.height = "0px";
}
});
// 如果内容未来动态变化(比如异步加载),你可以用这个函数更新高度
function refreshHeightIfOpen() {
if (!open) return;
const fullHeight = dropdown.scrollHeight;
const targetHeight = Math.min(fullHeight, MAX_HEIGHT);
dropdown.style.height = targetHeight + "px";
dropdown.style.overflowY = fullHeight > MAX_HEIGHT ? "auto" : "hidden";
}
// Demo: 2 秒后模拟新增内容,让你看到它依然能保持正确高度
setTimeout(() => {
const li = document.createElement("li");
li.className = "item";
li.innerHTML = `<span>新增 Item (异步)</span><span class="tag">ADD</span>`;
list.prepend(li);
refreshHeightIfOpen();
}, 2000);
</script>
</body>
</html>