PAYATHON 2026

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;

每次切换按以下顺序处理:

  1. 判断是否需要接管事件,保留 Ctrl + wheel 缩放,放行需要内部滚动的区域。
  2. 按 deltaMode 处理增量,过滤以横向滚动为主的事件。
  3. 增量累计到阈值后,将当前手势标记为已消费。
  4. 将目标索引限制在相邻区块,滚动到对应的普通流锚点。
  5. 动画期间丢弃后续切屏请求,同时继续记录是否仍有 wheel 事件到达。
  6. 等动画完成,且一段时间内没有新的输入事件后,再允许下一次切换。

控制器需要同时跟踪动画是否完成、当前手势是否已消费。只保留 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 展示层的布局,通常就能解决定位混乱。产品确实需要限制每次手势的切换次数时,再加入完整的切屏控制器。

备注:内容仅供参考。