Blog

纯翻页吸附的滚动导航设计

前端UX动画交互

不做自由滚动

首页是垂直排列的多个"板块"(section),每个板块约一屏高。常见的做法是让用户自由滚动,但在视觉密度极高的页面上,自由滚动有两个问题:

1. 用户很难对准某个板块——经常卡在两个板块之间,一半亮一半暗
2. 背景视频切换需要一个明确的"当前板块"信号,半屏状态很难判断

解决方案是纯翻页吸附——类似手机相册翻页,每次操作严格翻一页。用户输入变成明确的翻页指令,不再有中间态。

为什么用位置驱动而不是状态累加

状态累加方案(维护一个 currentIndex,翻页时 +1/-1)在理想情况下没问题,但任何异步干扰都会让它和实际位置脱节:浏览器 resize、内容加载导致高度变化、快速连翻时状态来不及更新。

位置驱动方案不依赖任何状态累加,每次翻页时:

1. 从视口中心点实时计算"我现在在哪个板块内"
2. 从这个板块出发,沿目标方向搜索"下一个板块在哪里"
3. 滚动到那个板块的顶部

换句话说,目标位置永远从当前的物理视口计算,出错自动修正,不需要"同步状态"这件事。

三输入统一

用户可能用鼠标滚轮、触摸手势、键盘导航三种方式翻页。三种输入走同一条路径:

wheel(capture 拦截) / touch(dy>30px触发) / keyboard(Arrow/Page/Space)
  → doSnap(getNextInDirection(dir))

核心函数 getNextInDirection(dir) 计算目标板块,与输入来源无关。移动端(pointer: coarse)完全跳过吸附,恢复 Lenis 自由滚动——手机上翻页吸附体验很差,不如让它正常滑动。

Portal 脱离滚动容器

SnapNav(吸附逻辑)和 Header(顶栏)通过 createPortal 渲染到 的直接子级,脱离 Lenis 平滑滚动的 wrapper。

原因:Lenis 用 CSS transform 实现平滑滚动,这会创建一个新的包含块(containing block),导致 position: fixed 的元素失去固定效果。Portal 到 根级后不受 Lenis wrapper 影响,position: fixed 正常工作。

罗盘导航

10 个圆点(1 个 Home + 9 个板块),实时高亮当前所在板块。点击圆点直接跳转。高亮判断逻辑也是从当前视口位置实时计算(findClosest())——不依赖缓存。

动画参数

- 吸附时长 0.2s,锁定窗口 280ms,每秒最多翻约 3.6 页
- 吸附使用 scrollTo({ behavior: 'smooth' }) 然后 setTimeout 解锁
- 切换板块时触发咔咔声(合成怀表转动 click 音效),给翻页一个听觉反馈

---

这个系统的设计原则是物理世界先于状态——如果物理位置和状态记录矛盾,相信物理位置。DOM 的 getBoundingClientRect 不会骗你。