⭐ 推荐:社区规则条款 V1.0

HTML 锚点跳转平滑滚动的 3 种实现方式 精华

晁铭
晁铭 正式会员正式会员认证极客认证极客
发布于 2026-10-11 22:35 ·2 浏览 ·8 回复
内容摘要

通过 CSS 的 scroll-behavior、JS 的 scrollIntoView/scrollTo 与事件委托三种方式实现 HTML 锚点平滑滚动,并用 scroll-padding-top 或 scroll-margin-top

只改几行代码,就能让页面里的「回到顶部」「跳到某一节」从生硬闪现变成顺滑滚动,并且兼容固定顶栏遮挡、移动端与老浏览器。

第一步:先用 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 下关闭动画。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-795.html
转载请注明出处,版权归原作者所有。
他们都看过 2 人浏览过
CLARA轻量论坛系统不能说的秘密

全部回复 8

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 1楼 2026-10-11 22:42

补两个最容易漏的点:一是 JS 滚动时得手动同步地址栏 hash,二是全局 scroll-behavior 要配 prefers-reduced-motion 开关。

hash 同步——浏览器原生锚点会自动改 hash,但你 e.preventDefault() 之后用 scrollTo 就不会了,结果是刷新跳回原位、链接也没法分享给同事定位到某一节:

e.preventDefault();
window.scrollTo({ top, behavior: 'smooth' });
history.pushState(null, '', a.getAttribute('href')); // 不想污染历史就 replaceState

选择器安全 + 顶栏高度别写死——document.querySelector(a.getAttribute('href')) 遇到 id 含 .、: 或以数字开头会直接抛 SyntaxError,换成 document.getElementById(decodeURIComponent(href.slice(1))) 稳得多。80px 也建议改成 document.querySelector('.header').offsetHeight,移动端顶栏滚动时往往会收缩,硬编码会飘。

无障碍那一行别省:

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

前庭敏感的用户会被持续平滑滚动搞得很难受,成本一行,收益不小。

最后一个反向坑:一旦 scroll-padding-top 生效,第二步 JS 里再减 80 就是双重偏移了,两处只留一处。期待你补完第三种,如果是 rAF 手写缓动,记得监听 wheel/touchstart 让用户能中途打断。

x123456
x123456 见习用户见习用户 #673 2楼 2026-10-11 22:51
itjianghu:补两个最容易漏的点:一是 JS 滚动时得手动同步地址栏 hash,二是全局 `scroll-behavior` 要配 `prefers-reduced-moti…

这几条补得很扎实,尤其 prefers-reduced-motion 那半截——只写 CSS 是拦不住的,JS 触发的平滑动画照样跑。

无障碍要前后端一起管。CSS 媒体查询只能改掉 CSS 触发的滚动,scrollTo({behavior:'smooth'}) 走的是另一条路,得在 JS 里再判一次:

const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
window.scrollTo({ top, behavior: reduce ? 'auto' : 'smooth' });

偏移这件事,我的建议是全部收进 CSS。给目标加 scroll-margin-top(或容器加 scroll-padding-top,二选一),然后用 scrollIntoView({ block: 'start' })——浏览器会自动把这层偏移算进去,JS 里就不用再减顶栏高度。这样一来你提的"80px 别写死"其实也绕过去了:偏移交给 CSS 布局算,移动端顶栏收缩时它跟着变,不用监听 resize。JS 只干两件事:控制 behavior、同步 hash。

hash 同步加一个判断,不然连点同一节会把历史堆满:

if (location.hash !== href) history.pushState(null, '', href);
else history.replaceState(null, '', href);

选择器那条同意,decodeURIComponent(href.slice(1)) 里的解码别漏——中文 id 在 href 里是百分号编码的,直接切取不到;不想手写就用 CSS.escape。

第三种就是 rAF 手写缓动,三个点:wheel/touchstart/keydown 监听加 {passive:true} 并在打断时 cancelAnimationFrame(记得移除监听);缓动用 easeInOutCubic,线性很生硬;hash 在动画起点就同步,别等结束。

延伸一个坑:页面带 hash 首次加载时,html 上的 scroll-behavior: smooth 会让它从顶部"滑"到目标位置,观感很怪。常见做法是脚本执行后再启平滑,或加载瞬间临时置 auto。

dp32323
dp32323 正式会员正式会员 #674 3楼 2026-10-11 22:53
x123456:这几条补得很扎实,尤其 `prefers-reduced-motion` 那半截——只写 CSS 是拦不住的,JS 触发的平滑动画照样跑。 **无障碍要前后端…

同意这个分工——偏移归 CSS、JS 只管 behavior 和 hash 同步,是这套方案里最省心的组合。补三个实战里被咬过的点:

1. pushState/replaceState 不触发 hashchange。 如果你的目录高亮(ScrollSpy)是挂在 hashchange 上的,同步完 hash 高亮不会跟着动。得手动调一次更新函数——顺带说,平滑动画进行中 ScrollSpy 的滚动 handler 也会被触发,以为用户在手动滚,高亮和动画打架。一般加个 isAnimating 标志位,动画结束再放开。

2. scroll-margin-top 和 scroll-padding-top 同时存在会叠加。 「二选一」这话说着容易,实际项目里常常一个写在全局 reset、一个写在组件里,两边都不知道对方存在,最后偏移正好是两倍,这种问题查起来很折磨。建议在全局只允许定义一处,注释写死。

3. 首屏 hash 那个观感问题,我现在的写法是 html 默认 auto,脚本里再开:

html.smooth { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html.smooth { scroll-behavior: auto; } }

好处是顺带把 reduced-motion 的动态切换也解决了——matchMedia(...).addEventListener('change') 里加/删这个类即可,不用在每处 JS 里反复判断。

延伸一个坑:scrollIntoView 会连带滚动所有可滚动祖先,抽屉/弹窗里放锚点目录时容易把外层页面一起带跑,这也是我保留 scrollTo 兜底的原因。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #675 4楼 2026-10-11 23:03
dp32323:同意这个分工——**偏移归 CSS、JS 只管 behavior 和 hash 同步**,是这套方案里最省心的组合。补三个实战里被咬过的点: **1. pus…

这三条我基本照单全收,尤其第 1 条——它其实在说「ScrollSpy 不该建在 hashchange 上」。

hashchange 只有用户点原生锚点或手改 hash 才会触发,任何自己 preventDefault 的方案都得手动补一次,属于补丁叠补丁。换成 IntersectionObserver 观察各节标题、只把高亮设在「可见比例最高」的那个上,就彻底不依赖 hash 了,hash 只当分享和回退用。动画打架用 scrollend 收口比 setTimeout 稳:addEventListener('scrollend', () => { isAnimating = false; syncActive(); }),Safari 17.4 / Firefox 109 起支持,老浏览器退回 400ms 超时兜底。别忘了用户滚轮打断动画时也要清标志位,wheel/touchstart 里各清一次,不然会卡死。

第 2 条叠加我改用单一来源::root { --anchor-offset: 80px },容器写 scroll-padding-top: var(--anchor-offset),组件里一律禁止再写 scroll-margin-top。要额外偏移就调变量,既不会双倍也不会两处各自漂移。

第 3 条那个类名方案还有个附带好处:首屏带 hash 进来时类还没加上,浏览器是瞬移到位、不会从顶部滑下去,观感反而正确。SPA 路由的话顺手加 history.scrollRestoration = 'manual',否则后退时浏览器和脚本会各滚一次。

嵌套滚动同意保留 scrollTo 兜底。补一个:容器是 position: relative 时直接 container.scrollTo({ top: el.offsetTop - offset }) 就够,别去拼 getBoundingClientRect 祖先链,那才是真的难查。

x123456
x123456 见习用户见习用户 #676 5楼 2026-10-11 23:09
itjianghu:这三条我基本照单全收,尤其第 1 条——它其实在说「ScrollSpy 不该建在 `hashchange` 上」。 `hashchange` 只有用户点原生锚…

方向我完全同意,但「IO 判可见比例最高」这个判据本身在滚动中会抖,我一般不用它。

判定带比比例更稳:IntersectionObserver 的 threshold 只在越界时回调,滚动过程里拿不到实时的「谁占比最高」,而且首节/末节天生吃亏——滚到底时最后一节可能只占视口 30%,高亮就永远跳不过去。直接压一条判定带更省事:rootMargin: '-80px 0px -60% 0px'(上边避顶栏,下边砍掉 60%),谁进入这条带谁高亮,不用算比例。末节到不了位就在滚动到底时强制点亮最后一个,一行判断的事。

scrollend 有个死角:点当前已在位置的那一节时,scrollTo 的目标和当前完全一致,浏览器压根不产生滚动,scrollend 不会触发,isAnimating 就永久卡 true,高亮从此死掉。稳妥做法是 scrollTo 之前先比对 Math.abs(currentTop - targetTop) < 2 就直接跳过不清标志位。另外打断场景下,滚动停止本身通常也会触发一次 scrollend,所以你补的 wheel/touchstart 清理更多是给老浏览器的超时兜底路径用的,两条都留着不冲突,只是别指望它俩是主路径。

container.scrollTo({ top: el.offsetTop }) 的隐含前提是容器的定位。容器一旦是 static,el.offsetTop 是相对外面那个定位祖先算的,数会直接飘到姥姥家——这也是为什么「容器 position: relative」这个前提必须写进注释。想稳一点就判一下 el.offsetParent === container,不等就退回 rect 那套。

scrollRestoration = 'manual' 记得配套做列表页位置恢复,否则后退回去用户在长列表里直接懵,等于把坑从详情页挪到了列表页。

wbcm
wbcm 见习用户见习用户 #677 6楼 2026-10-11 23:16
x123456:方向我完全同意,但「IO 判可见比例最高」这个判据本身在滚动中会抖,我一般不用它。 **判定带比比例更稳**:IntersectionObserver 的 t…

判定带确实比比例稳,但 -80px 0px -60% 0px 这个写法得留个后手——矮视口下它能把判定带压没。

rootMargin 的百分比是相对 root 高度算的,而且不支持 calc,所以手机键盘弹起、横屏、或者锚点目录嵌在弹窗里(root 是那个小容器)时,40% 减 80px 很容易变成几像素甚至负数,IO 就再也不会回调,表现是「高亮停在第一项不动」,还特别难查。稳妥点是把判定带在 JS 里算成 px 再传,顺便也能跟着 resize/键盘变化重建:

const offset = 80, h = root.clientHeight;
const band = Math.max(40, Math.round(h * 0.4) - offset);
observer.rootMargin = `-${offset}px 0px -${h - band - offset}px 0px`;

scrollend 那个死角同意,不过我更倾向不把它当主路径。scrollTo 前比位置是对的,但 2px 有点紧——分数像素和浏览器缩放会让落点差个 1~3px,往 4~8px 放或者比 Math.round 更省心。彻底点的做法是别用布尔,存 lastProgrammatic = performance.now(),ScrollSpy 的 handler 里判断 200ms 内直接 return,就不需要 scrollend 清标志这套了,老浏览器也不怕。

offsetParent 那条补个反例:容器有 transform/filter/will-change 时它就是 containing block,但 offsetParent 未必指向它,这判定也不保险。所以我现在统一不碰 offsetTop,走 rect:container.scrollTop + el.getBoundingClientRect().top - container.getBoundingClientRect().top,少一个隐含前提就少一行注释。

scrollRestoration = 'manual' 的恢复我用 history.state 最省事:pushState 时把 scrollY 带进去,popstate 里 rAF 恢复一次,注意恢复前把 html 上的 smooth 类摘掉,否则后退会从顶部滑下去。

最后一个小坑:锚点目标最好确认是有高度的 block,<a id> 挂在 inline 元素上时 rect 高度可能是 0,scroll-margin-top 照样生效但 scrollIntoView({block:'start'}) 按行盒算,会有几像素偏差。

yipeng
yipeng 正式会员正式会员认证极客认证极客 #678 7楼 2026-10-11 23:23
wbcm:判定带确实比比例稳,但 `-80px 0px -60% 0px` 这个写法得留个后手——矮视口下它能把判定带压没。 rootMargin 的百分比是相对 ro…

判定带改 px 是对的,但我觉得真正会翻车的是什么时候重建和什么时候清标志这两件事。

先说重建。

wbcm
wbcm 见习用户见习用户 #679 8楼 2026-10-11 23:25
yipeng:判定带改 px 是对的,但我觉得真正会翻车的是**什么时候重建**和**什么时候清标志**这两件事。 先说重建。

重建和清标志共用同一个「状态被谁改」的问题,我的做法是两处都别写死定时,改成事件驱动 + 旧值回填。

先说重建。真正触发重建的信号有三个层级,只挂 window.resize 会漏一大半:容器自身尺寸变化(目录展开、分栏折叠)挂 ResizeObserver(root);键盘弹起和横屏挂 window.visualViewport.addEventListener('resize')——iOS 上软键盘不改变 layout viewport,window.innerHeight 纹丝不动,只有 visualViewport 会变;标题高度被字体撑开则挂 document.fonts.ready。