跳到正文
返回

滚动叙事长页:平滑、固钉、视差和进度

约 4 分钟

很多获奖落地页看起来复杂,拆开之后常常只有几种滚动手法在换素材。这一篇把其中四种放进同一个长页:Hero 退场、逐行错开浮现、固钉加横向推进、视差,以及滚动驱动的数字。

下面的预览是零依赖版。在框里向下滚,就能看到每一屏在干什么。

在框里向下滚动,逐屏看四种手法。在新页面打开演示

核心主张就一句:

先把真实滚动位置换成 0 到 1 的进度,再用这个进度去驱动 transform 和 opacity。

GSAP 的 ScrollTrigger 和 Lenis 做的也是这件事。零依赖版把计算摊开写,方便对照。

边注

预览框里的滚动只发生在 iframe 内部,不会拖动博客正文。

平滑滚动在算什么

浏览器原生滚动是跳变的。滚轮一格,位置立刻过去。平滑滚动把真实 scrollY 当目标值,每帧用插值朝它靠近:

// public/demos/scroll-narrative.html
target = window.scrollY;
current += (target - current) * 0.09;
content.style.transform = `translate3d(0,${-current}px,0)`;

内容容器设成 position: fixed,再用 document.body.style.height 撑出真实滚动行程。系数 0.09 是手感旋钮:越小越黏,超过 0.15 就几乎看不出平滑。

固钉没有魔法

固钉分两步:

  1. 外层容器给很高的高度,这里是 420vh,只负责占据滚动行程。
  2. 当容器顶部越过视窗顶部,给内层同等距离的反向位移。
const rect = wrap.getBoundingClientRect();
const total = wrap.offsetHeight - vh;
const offset = clamp(-rect.top, 0, total);
inner.style.transform = `translate3d(0,${offset}px,0)`;
const progress = clamp(offset / total, 0, 1);

视觉上内层停住了,同时你拿到一个 0 到 1 的进度值。横向推进就是:

const dist = Math.max(track.scrollWidth - innerWidth + 40, 0);
track.style.transform = `translate3d(${-progress * dist}px,0,0)`;

数字计数也是同一套进度:Math.round(progress * 100)

视差用相对位置,不用全局滚动量

视差看的是元素在视窗里的相对位置,这样进入和离开都对称:

const r = section.getBoundingClientRect();
const centered = (vh / 2 - (r.top + r.height / 2)) / vh;
layer.style.transform = `translate3d(0,${centered * speed * 180}px,0)`;

speed 取负数就是反向移动。整个行程内位移别超过 200px,否则会很假。

进场别只做淡入

单纯的 opacity: 01 像幻灯片。更稳的组合是位移、透明度和错开延迟。演示里每行比上一行晚 80ms,缓动用 cubic-bezier(.16, 1, .3, 1):开头快,结尾慢。

项目里用 GSAP + Lenis 怎么写

实际项目里不必手写固钉。把 Lenis 和 GSAP 接到同一个 ticker,否则滚动和动画会各跑各的,画面会抖:

import gsap from "gsap";
import ScrollTrigger from "gsap/ScrollTrigger";
import Lenis from "lenis";

gsap.registerPlugin(ScrollTrigger);

const lenis = new Lenis({ lerp: 0.09 });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);

gsap.to(track, {
  x: () => -(track.scrollWidth - innerWidth + 40),
  ease: "none",
  scrollTrigger: {
    trigger: "#pinWrap",
    pin: "#pinInner",
    start: "top top",
    end: "bottom bottom",
    scrub: 0.6,
    invalidateOnRefresh: true,
  },
});

x 写成函数,并打开 invalidateOnRefresh,窗口变宽后横向距离才会重算。动画只写 transformopacity,不要去改 lefttopwidth

想改手感时动这些数

想调什么改哪里
滚动黏度lerp 或插值系数 0.09
固钉时长.pin-wrapheight: 420vh
卡片宽度.cardflex-basis
错开节奏每行 80ms
视差幅度speed * 180 里的 180

在新页面打开完整演示。上一篇同专栏:用原生 WebGL 跑 24,000 个粒子


分享这篇文章:

接着读

  1. MCP 草案改了什么:会话和握手都被删掉了本文整理自 MCP 官方规范草案变更日志,对比 2025-11-25 版本,重点看会话、握手、MRTR 和迁移影响。
  2. 最新速递短、快、一事一篇。看到新工具、长文或线上现象时随手记下的入口。
  3. 拆 Orloj,Agent 运行时、控制面和嵌入式控制台细拆 Orloj 的单 Agent 循环、多 Agent 图路由、工具与治理,以及 Go 控制面、Postgres 租约和 React 控制台怎么接上同一套资源。带核心代码与结构图。

Discussion

评论

用 GitHub 账号登录即可留言。想法、纠错、补充都欢迎。