只改几行代码,就能让页面里的「回到顶部」「跳到某一节」从生硬闪现变成顺滑滚动,并且兼容固定顶栏遮挡、移动端与老浏览器。
第一步:先用 CSS 解决 90% 的场景
最简单的做法是给滚动容器加一行声明。绝大多数情况写给 html 就够:
html {
scroll-behavior: smooth;
}
保存刷新,页面上所有 href="#section-2" 这类锚点链接、以及 JS 触发的 location.hash 跳转,都会自动带过渡动画。
如果你的页面滚动条不在 body 上,而是在某个内部容器(比如聊天记录区、弹窗内容区),那就要写在那个容器上:
.chat-body {
overflow-y: auto;
scroll-behavior: smooth;
}
固定顶栏遮挡标题是这一步最常翻车的地方。给锚点目标留出顶栏高度的偏移:
html {
scroll-behavior: smooth;
scroll-padding-top: 80px; /* 顶栏 80px 高 */
}
scroll-padding-top 是加在滚动容器上的;也可以反向写在目标元素上,用 scroll-margin-top: 80px,效果等价,按团队习惯选一种即可。
注意:scroll-behavior 一旦设在 html 上就是全局生效,如果你只想让目录跳转平滑、其他跳转保持瞬时,请不要用这一步,直接看第二步。另外 Safari 需要 15.4 以上才支持,再老的版本会直接瞬间跳过去——功能不受影响,只是没有动画。
第二步:用 JS 精确控制,只对指定链接生效
不想全局开启时,用 scrollIntoView 或 scrollTo,把平滑行为写在调用参数里:
document.querySelector('#section-2')
.scrollIntoView({ behavior: 'smooth', block: 'start' });
也可以按像素滚动,配合固定顶栏时更好用:
const el = document.querySelector('#section-2');
const top = el.getBoundingClientRect().top + window.pageYOffset - 80; // 减顶栏高度
window.scrollTo({ top, behavior: 'smooth' });
如果页面上锚点很多,不要一个个绑事件,用事件委托一次搞定:
document.addEventListener('click', (e) => {
const a = e.target.closest('a[href^="#"]');
if (!a) return;
const target = document.querySelector(a.getAttribute('href'));
if (!target) return;
e.preventDefault();
const top = target.getBoundingClientRect().top + window.pageYOffset - 80;
window.scrollTo({ top, behavior: 'smooth' });
history.pushState(null, '', a.getAttribute('href')); // 地址栏保留 #锚点
});
history.pushState 这一句别省:不加的话地址栏不会变,用户刷新或复制链接就丢了当前定位。
注意:e.target.closest 要求 e.target 是元素节点,点到文本节点或 SVG 里的 use 时可能报错,稳妥写法是 e.target instanceof Element ? e.target.closest(...) : null。另外 querySelector(a.getAttribute('href')) 遇到 href="#" 这种空锚点会抛异常,记得先用正则过滤掉。
第三步:需要自定义时长和缓动时,手写 rAF 动画
前两步的滚动速度由浏览器决定(大约 300~500ms,无法改),而且中途无法优雅打断。要求更高——比如固定 600ms、用 easeInOutCubic,或需要「用户一滚鼠标就停下动画」——就自己写:
function smoothScrollTo(targetY, duration = 600) {
const startY = window.pageYOffset;
const diff = targetY - startY;
let start;
let rafId;
function step(ts) {
if (start === undefined) start = ts;
const p = Math.min((ts - start) / duration, 1);
const eased = p < 0.5 ? 4 * p ** 3 : 1 - (-2 * p + 2) ** 3 / 2; // easeInOutCubic
window.scrollTo(0, startY + diff * eased);
if (p < 1) rafId = requestAnimationFrame(step);
}
rafId = requestAnimationFrame(step);
// 用户手动滚动时立即中断,避免跟用户抢滚动条
const cancel = () => { cancelAnimationFrame(rafId); window.removeEventListener('wheel', cancel); };
window.addEventListener('wheel', cancel, { once: true });
return cancel;
}
调用:smoothScrollTo(el.getBoundingClientRect().top + window.pageYOffset - 80)。
这里的 easeInOutCubic 公式是关键:线性滚动(startY + diff * p)看起来会很机械,加上缓动才自然。想换手感就把那行公式替成别的曲线,时长改 duration 即可。
注意:window.pageYOffset 在部分老环境里要用 document.documentElement.scrollTop 兜底,两者取存在的那个。另外手写动画会不断调用 window.scrollTo,如果页面里有 scroll 事件监听做懒加载或吸顶判断,会触发得很频繁,函数里注意节流。
最后补一条无障碍处理,三种方式都建议加上,让晕动症用户免受动画干扰:
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
小结
- 只求能用:
html { scroll-behavior: smooth; } 一行搞定,配 scroll-padding-top 处理固定顶栏。
- 只对部分链接生效:JS 里
scrollIntoView({behavior:'smooth'}) 或 scrollTo({top, behavior:'smooth'}),用事件委托统一拦截 a[href^="#"]。
- 要控时长/缓动/可中断:
requestAnimationFrame 手写缓动函数,别忘 cancelAnimationFrame。
- 三条通用坑:固定顶栏要减高度或加
scroll-padding-top;JS 跳转后调 history.pushState 保留地址栏锚点;prefers-reduced-motion 下关闭动画。