Next.js + Tailwind 全屏 scroll snapping 跳过区块或卡住,如何解决?
建议先移除全局 wheel 拦截,改用明确的滚动容器配合 CSS Scroll Snap。如果要保留 sticky 堆叠效果,就为每个区块设置独立的普通流锚点:锚点负责吸附和导航,sticky 元素负责视觉效果。
具体方案取决于你需要哪种滚动行为:
- 滚动结束后停在区块边界:优先使用 CSS Scroll Snap。
- 一次触控板手势无论滑多远,都只能切换一个区块:需要自定义切屏控制器。浏览器没有统一的
wheel手势结束事件,只能自行约定识别规则,无法保证所有设备上的物理手势都能被一致识别。
scroll-snap-stop: always 可以限制滚动越过吸附点,但无法把任意一串 wheel 事件自动识别为“一次手势”。
为什么现有代码会跳屏或卡住?
wheel 事件与一次手势不对应
鼠标滚轮和触控板产生的事件数量、频率和增量都不同。触控板在手指离开后,还可能继续发送惯性事件。
原代码在动画结束后立即解除 isAnimating。如果此时仍有惯性事件到达,增量就可能再次累计到阈值,触发下一屏。
deltaY 的单位还取决于 deltaMode,可能是像素、行或页。直接与固定值 80 比较,在不同设备上代表的滚动量并不一致。
scrollBy(innerHeight) 会保留已有偏移
假设区块起点是 0、900、1800,当前滚动位置是 930。执行:
window.scrollBy({
top: 900,
behavior: "smooth",
});
目标位置会是 1830,仍然偏离区块起点。
区块间距还可能受到实际高度、外边距、内容溢出和页眉影响。逐屏导航应先根据区块索引确定目标,再滚动到它的绝对坐标。
scrollend 不能解决手势识别问题
scrollend 表示滚动完成,后续仍可能收到同一次触控板操作产生的惯性事件。
原代码还存在一种可能导致永久锁定的边界情况:页面已经滚到底部,继续调用 scrollBy() 时,位置可能完全不变。没有发生滚动,就不能保证会收到 scrollend。
固定等待 600ms 也无法确认动画是否结束,既可能提前解除锁定,也可能让用户多等一段时间。
先明确谁负责滚动
吸附、事件监听和坐标计算都应针对实际滚动的对象配置。两种结构都可以使用:
- 页面滚动:使用文档滚动容器。
- 局部滚动:使用具有明确高度和
overflow-y: auto的元素。
原来的 md:h-screen 只设置高度,不会自动创建局部滚动区域。如果实际滚动的是页面,把 snap-y 加在内部 <main> 上通常无法得到预期效果。
下面的实现使用独立滚动容器,桌面端在容器内滚动,移动端恢复普通页面滚动。
保留 sticky 堆叠的实现
sticky 元素仍然占据普通文档流的位置,但吸顶后,getBoundingClientRect().top 会受到 sticky 偏移影响。因此,下面的计算结果不能始终代表它原来的布局位置:
window.scrollY + el.getBoundingClientRect().top
可以在每个 sticky 区块前放置一个不参与 sticky 的定位锚点。所有 sticky 区块共享同一个父容器,避免各自受到一屏高包装层的限制。
"use client";
import { Fragment } from "react";
const sections = [
{ id: "hero", title: "首页", background: "#f4f1eb" },
{ id: "about", title: "关于", background: "#e5edf0" },
{ id: "contact", title: "联系", background: "#e9e4ef" },
];
export function HomePageClient() {
return (
<div
className="section-scroller"
role="region"
aria-label="页面区块"
tabIndex={0}
>
<main className="section-stack">
{sections.map((section, index) => (
<Fragment key={section.id}>
<div
id={section.id}
data-section-anchor
className="section-anchor"
aria-hidden="true"
/>
<section
className="section-panel"
style={{
zIndex: index + 1,
backgroundColor: section.background,
}}
aria-labelledby={`${section.id}-heading`}
>
<h1 id={`${section.id}-heading`}>
{section.title}
</h1>
{/* 放置对应区块的内容 */}
</section>
</Fragment>
))}
</main>
<footer className="section-footer">
页脚内容
</footer>
</div>
);
}
对应 CSS:
.section-stack {
position: relative;
isolation: isolate;
}
.section-panel {
position: relative;
min-height: 100dvh;
}
.section-anchor {
height: 0;
}
@media (min-width: 1024px) {
.section-scroller {
height: 100dvh;
overflow-y: auto;
scroll-snap-type: y mandatory;
}
.section-anchor {
scroll-snap-align: start;
scroll-snap-stop: always;
}
.section-panel {
position: sticky;
top: 0;
}
.section-footer {
min-height: 100dvh;
scroll-snap-align: start;
scroll-snap-stop: always;
}
}
应用这套布局时,需要注意:
id已移到锚点上,要移除原区块中的同名id。- 后面的区块需要更高的
z-index和不透明背景,才能覆盖前面的区块。 - 不要为每个“锚点 + sticky 区块”单独套一个只有一屏高的父容器,否则 sticky 几乎没有可移动空间。
- 示例将页脚也设为一屏高。如果页脚较短,并且希望用户自由浏览,可以调整它的吸附行为,无须强行拉到一屏。
- 移除原来的
md:sticky、md:h-screen等冲突类,避免与这些规则叠加。
Tailwind 的默认断点中,md 从 768px 开始,lg 从 1024px 开始。原代码中 sticky 和 JavaScript 的启用范围并不一致;如果项目修改过断点,应以项目配置为准。
导航也要滚动到锚点
改用局部滚动容器后,导航坐标应基于容器的 scrollTop 计算,不能继续使用 window.scrollY。
以下函数适用于上述桌面容器:
function scrollToAnchor(
scroller: HTMLElement,
anchor: HTMLElement,
) {
const target =
scroller.scrollTop +
anchor.getBoundingClientRect().top -
scroller.getBoundingClientRect().top -
scroller.clientTop;
const maxScroll =
scroller.scrollHeight - scroller.clientHeight;
scroller.scrollTo({
top: Math.max(0, Math.min(target, maxScroll)),
behavior: window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches
? "instant"
: "smooth",
});
}
移动端没有这个局部滚动区域时,可以对普通流锚点调用 scrollIntoView()。
如果固定导航栏会遮挡内容,可以在区块内部预留空间。若使用 scroll-padding-top,吸附位置和手动导航坐标也要采用一致的偏移规则。
严格要求一次手势只切换一屏时怎么办?
需要实现独立的切屏控制器,并关闭同一容器上的 CSS 吸附,避免浏览器和 JavaScript 同时修正滚动位置。
控制器至少需要分别记录以下状态:
let currentIndex = 0;
let targetIndex = 0;
let isAnimating = false;
let gestureConsumed = false;
let accumulatedDelta = 0;
每次切换按以下顺序处理:
- 判断是否需要接管事件,保留
Ctrl + wheel缩放,放行需要内部滚动的区域。 - 按
deltaMode处理增量,过滤以横向滚动为主的事件。 - 增量累计到阈值后,将当前手势标记为已消费。
- 将目标索引限制在相邻区块,滚动到对应的普通流锚点。
- 动画期间丢弃后续切屏请求,同时继续记录是否仍有
wheel事件到达。 - 等动画完成,且一段时间内没有新的输入事件后,再允许下一次切换。
控制器需要同时跟踪动画是否完成、当前手势是否已消费。只保留 isAnimating,无法挡住动画结束后继续到达的惯性尾部事件。
多长时间没有输入才算手势结束,需要按产品采用的手势分组规则,结合目标设备调整。用户连续转动鼠标滚轮时,控制器也可能将这些事件视为同一组输入,需要提前确定是否接受这种行为。
动画完成如何判断?
继续使用原生平滑滚动时,需要:
- 在发起滚动前,监听实际滚动对象的
scrollend。 - 先检查是否已经到达目标,以及目标坐标是否被滚动边界截断。
- 同时检查当前位置与目标位置的距离。
- 设置超时以便故障退出,并做好清理;超时本身不能证明已成功到达目标。
- 在动画取消、组件卸载或切换到移动布局时,清理监听器、定时器和锁。
严格切屏也可以用 requestAnimationFrame 自行控制动画进度,以便确定动画何时结束。这种方式需要自行处理用户中断、尺寸变化和减少动态效果偏好,并关闭容器的平滑滚动与 CSS 吸附。
导航按钮、键盘操作和 URL hash 也应由同一个控制器处理,否则导航发起的滚动可能与滚轮动画互相覆盖。
原组件还需要调整哪些地方?
尺寸变化时重新启停
以下判断只在组件挂载时执行一次:
if (window.innerWidth < 1024) return;
窗口宽度跨过断点后,它不会自动更新。手动控制器应使用 matchMedia("(min-width: 1024px)") 并监听 change,在禁用时取消动画、释放状态。
挂载时立即测量
组件挂载时,window.load 可能已经触发,因此应立即测量,不能只等待该事件:
useEffect(() => {
measureCoords();
window.addEventListener("resize", measureCoords);
return () => {
window.removeEventListener("resize", measureCoords);
};
}, []);
测量对象应改为普通流锚点。如果图片、字体或预加载器会改变布局,还需要在相应布局稳定后重新测量。固定等待 150ms 无法确认页面是否已经准备好。
不要用裁剪掩盖高度问题
overflow-hidden 会直接隐藏超出区块的内容。文字放大、浏览器缩放或桌面窗口尺寸较小时,内容都可能超过一屏。
应优先保证内容可访问。对于可能超高的区块,可以取消 sticky、采用 proximity 吸附,或设置明确的内部滚动区域。使用手动控制器时,还必须让内部区域优先消费滚动。
这类页面先做好独立滚动容器、普通流锚点和 sticky 展示层的布局,通常就能解决定位混乱。产品确实需要限制每次手势的切换次数时,再加入完整的切屏控制器。
备注:内容仅供参考。
