Skip to content
Go back

Scroll storytelling: smooth scroll, pin, parallax, progress

约 4 分钟

Many award-style landing pages look complex until you pull them apart. A large share of them reuse a small set of scroll techniques with different art. This post puts four of them on one long page: hero exit, staggered entrance, pinned horizontal travel, parallax, and a scroll-driven number.

The preview below is the zero-dependency version. Scroll inside the frame to see what each screen does.

Scroll inside the frame to walk through the four techniques.Open the demo in a new page

The core claim is short:

Convert real scroll position into progress from 0 to 1, then drive transform and opacity with that progress.

GSAP ScrollTrigger and Lenis do the same work. The standalone page writes the math out so you can compare.

Note

Scrolling stays inside the iframe. It does not move the blog page.

What smooth scrolling calculates

Native browser scrolling jumps. One wheel step moves the page at once. Smooth scrolling treats the real scrollY as a target and eases toward it every frame:

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

The content root is position: fixed. document.body.style.height creates the real scroll distance. The 0.09 coefficient is the feel knob: smaller values feel stickier, and values above 0.15 barely look smoothed.

Pinning is two steps

Pinning has no special browser API here:

  1. Give the outer wrap a tall height, 420vh in this demo, so it owns scroll distance.
  2. When the wrap top crosses the viewport top, apply an equal reverse offset to the inner layer.
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);

The inner layer looks fixed, and you get progress from 0 to 1. Horizontal travel is:

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

The counter uses the same progress: Math.round(progress * 100).

Parallax uses relative position

Parallax should use the section’s position inside the viewport, not global scroll distance, so enter and leave stay symmetric:

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

A negative speed moves the opposite way. Keep travel under 200px across the whole pass, or the motion looks cheap.

Entrance needs more than a fade

A plain opacity change from 0 to 1 looks like a slide deck. A steadier stack is motion, opacity, and stagger. In the demo each line waits 80ms longer than the previous one, with cubic-bezier(.16, 1, .3, 1) for a fast start and soft landing.

GSAP + Lenis in a real project

You do not need to hand-roll pinning in production. Wire Lenis into the GSAP ticker so scroll and animation share one clock:

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,
  },
});

Keep x as a function and turn on invalidateOnRefresh, so a resize recomputes the travel distance. Animate only transform and opacity. Do not animate left, top, or width.

Knobs worth changing

GoalWhere
Scroll viscositylerp or the 0.09 easing factor
Pin duration.pin-wrap height 420vh
Card width.card flex-basis
Stagger timing80ms per line
Parallax travelthe 180 in speed * 180

Open the complete demo in a new page. Previous Frontend Lab post: Running 24,000 particles with raw WebGL.


Share this post:

接着读

  1. What changed in the MCP draft: sessions and handshake are goneNotes from the official MCP spec draft's changelog, compared against the 2025-11-25 version, focused on sessions, the handshake, MRTR, and migration impact.
  2. Latest digestShort, one-thing notes. A new tool, a long read, or a production symptom — the entry hub.
  3. Breaking down Orloj: Agent runtime, control plane, and embedded consoleA close read of Orloj's single-agent loop, multi-agent graph routing, tools and governance, and how the Go control plane, Postgres leases, and React console wire into the same resources. With core code and diagrams.

Discussion

评论

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