Skip to content
02 / 10Scroll8 min read

Velocity

How fast you're scrolling is a design input, and the browser never hands you a usable number for it. One line of smoothing turns a spiky per-frame delta into the signal half this site leans on.

Used on this site

Open any case study from the work index and scroll. The hero lines lean as they leave. The faster you go, the further they lean, up to four degrees and no further.

Paused
Scroll the page and watch the trace, it's live. Pale is the signal the site publishes; dark is that same signal re-smoothed at whatever factor you pick. Push the slider to 1.0 and the two collapse into one line.

The site leans when you scroll, and the number it leans on does not exist until you build it.

Six things on this site read how fast you're moving: the case-study hero skews, image plates tilt, the dissolve preview and the palette panel rotate, the home scene dollies back and rolls, and the menu's dissolve tears harder when you leave a page at speed. All six read the same number, published once per frame by the master requestAnimationFrame loop in ExperienceProvider.

The obvious way to produce that number is to subtract last frame's scrollY from this frame's. Try it, wire it to a skewY, and watch. The result is unusable in two distinct ways, and they're easy to mistake for one.

The delta is not the signal

The first failure is spikiness. A wheel notch isn't a smooth push, it's an impulse, so the delta between two frames is a run of large numbers with no relationship to their neighbours, and anything driven by it vibrates. The second failure is worse and much less obvious: on any frame where no input landed, the delta is exactly zero. Input arrives at whatever rate the device chooses, not the display's, so a scroll that feels continuous is really a burst of movement and then several frames of nothing. Bind a transform straight to the delta and the element snaps back to rest between events. It flickers rather than leans. That's the bug that sends people hunting for a bigger multiplier when the real problem is the shape of the signal.

Lenis blunts both of those, and it would be dishonest to describe the filter without saying so. The loop advances lenisRef.current?.raf(t) at the top of the frame, before it reads scrollY, so on a wheel the position it reads has already been interpolated at Lenis's own lerp: 0.08. But Lenis smooths the position, not the derivative. Each new notch moves its target and the delta steps with it. And Lenis is never constructed at all when the visitor asks for reduced motion. The loop still has to produce a number it can hand to a transform, and the line that does it is short.

src/experience/ExperienceProvider.tsx — the master loop
// rAF is throttled in a background tab but not stopped, and the work is
// pointless either way.
if (document.hidden) {
  lastT = 0;
  return;
}
lenisRef.current?.raf(t);
// first frame after a resume gets a nominal step, not a two-minute one
const dt = lastT ? Math.min((t - lastT) / 1000, 0.1) : 1 / 60;
lastT = t;

mouse.ex += (mouse.x - mouse.ex) * fps60(0.06, dt);
mouse.ey += (mouse.y - mouse.ey) * fps60(0.06, dt);

const df = darkFactor();
One line makes the signal usable, and one publishes it. Everything else in this article is downstream of those two.

vel += (scroll - lastScroll - vel) * 0.2 is a one-pole low-pass filter written as a lerp. It chases the delta rather than equalling it, which fixes both failures at once: the impulse gets spread across the frames after it, and a zero delta pulls the value toward zero over several frames instead of dropping it there. The sign is kept, downward positive, because a lean that only knows speed and not direction leans the wrong way half the time.

What one lerp term buys

0.2 closes one fifth of the remaining gap per frame. Against a sustained input the value is 67% of the way there after five frames and 89% after ten, call it a sixth of a second to be effectively caught up. That's the whole tuning problem in one constant, and both ends of the demo's slider are wrong in instructive ways.

At 0.05 the dark trace is beautifully smooth and arrives late: stop scrolling and the page is still leaning a third of a second later, which reads as the site lagging rather than responding. At 1.0 the second pass disappears and the two traces sit exactly on top of each other. That end is honest in a different way: there's no path from here to the raw delta, because the provider consumes it inside its loop and never publishes it. What you're always looking at is the published signal, plus however much extra smoothing you dial in. The usable band is narrow, roughly 0.15 to 0.3, and it sits above every other smoothing constant in the loop because velocity is the one the reader is actively driving with their hand.

ConstantDefault
velocity lerp0.2the scroll signal. Below 0.1 the lean trails the scroll visibly; above 0.4 the wheel impulses come through as jitter
pointer lerp0.06cursor easing in the same loop. Far slower on purpose: a cursor is followed by the eye, and a trailing dot reads as weight rather than lag
dark lerp0.08the page-end light-to-dark blend. A whole-page colour change wants to be gradual; there is nothing to be late for
chromeDark lerp0.16the nav's light/dark flip. Faster than dark because it has to keep pace with a section edge sweeping through a 64px band

Published twice, on purpose

The loop hands out state in two forms. Subscribers registered through useTick receive a TickState object: eight fields, one allocation per frame, delivered to every callback in the set. That's the normal path, and it's what the hero, the plates and the WebGL scene use.

src/experience/ExperienceProvider.tsx — TickState
export type TickState = {
  t: number;
  dark: number;
  /** 0→1 while a dark SURFACE (footer dark zone or a [data-darksurface]
   *  section, e.g. a case study's flagship band) sits under the fixed chrome —
   *  the nav mixes toward bone on this, so mid-page dark sections don't
   *  swallow the wordmark and menu the way they did when only the page-end
   *  `dark` factor existed. */
  chromeDark: number;
  menuReveal: number;
  mouseEx: number;
  mouseEy: number;
  scroll: number;
  /** Smoothed scroll velocity, signed, +down. Drives skew and parallax.
   *
   *  Measured in px/second internally and divided by 60 on publication, so the
   *  NUMBER every consumer sees is the px-per-60Hz-frame it always was — while
The frame's entire public surface. Adding a ninth field is a decision, which is the point of writing it as a type.

The same value also gets written to velocityRef, and that isn't redundancy. The menu's plasma runs its own requestAnimationFrame loop, which stops itself entirely once the overlay has retreated, so a closed menu costs zero GPU. Code that parks its own loop can't be a tick subscriber, because it would be woken sixty times a second by the callback it registered. So it samples the ref when it wakes: Math.min(Math.abs(velocityRef.current) * 0.015, 1), captured at the instant a navigation fires and then decayed. Leave a page at speed and the dissolve tears harder. Leave it from rest and it goes calmly.

Everything downstream is one multiply

The consumers are deliberately trivial. No per-element easing, no second filter, no spring. The signal was made usable once, upstream, so every element that reads it is a gain and a clamp. That only holds because the signal is produced in the render loop. Sample the scroll position in a scroll listener instead and each consumer gets a value captured at a moment the browser chose, stale by an unknown fraction of a frame, and two elements reading it in the same paint can disagree about how fast the page is moving.

src/components/work/CaseStudy.tsx — hero scroll-out
const meta = heroMetaRef.current;
const sk = reducedRef.current ? 0 : clamp(s.velocity * 0.02, -4, 4);
seq.forEach((l, i) => {
  if (!l) return;
  // at rest at the top, force the settled state
  if (s.scroll <= 2) {
    l.style.transform = "none";
    l.style.opacity = "1";
    return;
  }
  const p = clamp(hp * 1.2 - i * 0.06, 0, 1);
  const e = easeOutCubic(p);
  const rise = reducedRef.current ? 0 : e * 46; // px — the section-like nudge
  if (!reducedRef.current) {
    l.style.transform = `translateY(${(-rise).toFixed(1)}px) scaleY(${(1 + e * 0.04).toFixed(3)}) skewY(${sk.toFixed(2)}deg)`;
  }
  // collision-aware fade so hero copy never overlaps the fixed logo/wordmark
  const screenTop = meta[i].top - s.scroll - rise;
  l.style.opacity = clamp((screenTop - 70) / 40, 0, 1).toFixed(3);
});
The lean, the rise and the collision fade, in one tick. The transform is a single string per line per frame.

* 0.02 clamped to ±4 means the skew saturates at 200 px/frame of smoothed velocity and can never exceed four degrees. Without the clamp, a momentum fling maps straight to an arbitrarily large skew and the biggest type on the site briefly goes italic by accident. Notice also that sk is forced to zero under reduced motion while the opacity fade survives. The collision rule that keeps hero copy from sitting under the wordmark is an accessibility behaviour, not a flourish, so the transform goes and the fade stays.

  • CaseStudy hero: * 0.02, ±4°, as skewY on each hero line
  • MediaPlaceholder: * 0.16, ±4°, riding on top of a parallax offset of 6% of the plate's height
  • DissolvePreview: * 0.06, ±3°, as a rotate on the floating preview
  • PaletteBoard: * 0.05, ±3°, as a rotate on the floating panel
  • WebGLCanvas: * 0.002 capped at 0.45 of camera dolly, plus * 0.00035 clamped to ±0.05 rad of roll
  • MenuPlasma: * 0.015 capped at 1, off the ref, decaying at 0.95 per frame
src/components/lab/demos/VelocityDemo.tsx — the seventh consumer
const params = useRef({ lerp: 0.2, skew: true });
const live = useRef({ raw: 0, smooth: 0, history: new Array<number>(HISTORY).fill(0) });

// the published signal, plus a second smoothing pass at the reader's factor.
// at 1.0 the second pass is a no-op and the two traces coincide
useTick((s) => {
  const l = live.current;
  l.raw = s.velocity;
  l.smooth += (s.velocity - l.smooth) * params.current.lerp;
  l.history.push(s.velocity);
  if (l.history.length > HISTORY) l.history.shift();
});
The demo reads the same value off the same tick as the six above. The gains across those six span a factor of four hundred, all tuned by eye against one quantity. That's the argument for producing the quantity once.

P.S. This one is running on the site right now.

Scroll · Motion ·
← Back to the lab
Creative Web Designer & Developer25.2048°N · 55.2708°E

AlexanderSmith

Locating the studio25.2048°N · 55.2708°E