Skip to content
02 / 10Scroll8 min read

Velocity

How fast you are scrolling is a design input, and the number the browser gives you is not usable as one. A single exponential term turns a spiky per-frame delta into a signal the whole 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 — the trace is 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 components on this site read how fast you are moving: the case-study hero skews, image plates and the dissolve preview tilt, the palette panel rotates, 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 this frame’s scrollY from last frame’s. Do that and wire it to a skewY and the result is unusable, in two distinct ways that are easy to confuse for one.

The delta is not the signal

The first failure is spikiness. A wheel notch is not a smooth push, it is an impulse, so the delta between two frames is a run of large numbers with no relationship to its neighbours, and anything driven by it vibrates. The second is worse and much less obvious: on any frame where no input landed, the delta is exactly zero. Input arrives at the rate the device chooses, not the display’s, so a scroll that feels continuous is 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 is the bug that sends people hunting for a bigger multiplier when the 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 it is never constructed at all when the visitor has asked for reduced motion. The loop still has to produce a number it can hand to a transform, and the term that does it is one line long.

src/experience/ExperienceProvider.tsx — the master loop
const scroll = window.scrollY || document.documentElement.scrollTop;
vel += (scroll - lastScroll - vel) * 0.2; // smoothed px/frame
lastScroll = scroll;
velocityRef.current = vel; // published for code that parks its own loop and cannot subscribe
const state: TickState = {
  t,
  dark: k,
  chromeDark: chromeDarkRef.current,
  menuReveal: menuRevealRef.current,
  mouseEx: mouse.ex,
  mouseEy: mouse.ey,
  scroll,
  velocity: vel,
};
ticksRef.current.forEach((cb) => cb(state));
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 is 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 is 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, so against a sustained input the value is 67% of the way there after five frames and 89% after ten — a sixth of a second to be effectively caught up. That is the whole tuning problem in one constant, and both ends of the slider on the demo above are wrong in an instructive way.

At 0.05 the signal is beautifully smooth and arrives late: stop scrolling and the page is still visibly leaning a third of a second later, which reads as the site lagging rather than responding. At 1.0 there is no filter left — vel is the bare delta again, zero frames and all. The usable range is narrow, roughly 0.15 to 0.3, and it sits above every other smoothing term in the loop because velocity is the only 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. Deliberately far slower — 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 is the normal path, and it is 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 in px/frame (signed; +down). Drives skew/parallax. */
  velocity: number;
};
type Tick = (s: TickState) => void;
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 is also written to velocityRef, and that is not redundancy. The menu’s plasma runs its own requestAnimationFrame loop, which stops itself entirely once the overlay has retreated — zero GPU at rest. Code that parks its own loop cannot be a tick subscriber, because it would be woken sixty times a second by the callback it registered. Instead 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, so leaving a page at speed tears the dissolve harder than leaving it at rest.

Everything downstream is one multiply

The consumers are deliberately trivial. There is 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
    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);
  });
});

return (
  <LightboxProvider>
  <div data-view="case-study">
    {/* ================= HERO ================= */}
    <header ref={headerRef} className="relative z-[1] px-gutter pt-[clamp(116px,17vh,190px)] pb-[clamp(28px,5vh,52px)]">
      <a
        ref={backRef}
        href="/work"
        data-cursor="enter"
        onClick={(e) => {
          e.preventDefault();
          navigate("/work");
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 becomes italic by accident. Note also that sk is forced to zero under reduced motion while the opacity fade survives — the collision rule that stops hero copy from sitting under the wordmark is an accessibility behaviour, not a flourish, so it is the transform that goes and not the fade.

  • 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
// the sketch reads these every frame; changing one must not re-seed the trace
const params = useRef({ lerp: 0.2, skew: true });
const live = useRef({ raw: 0, smooth: 0, history: new Array<number>(HISTORY).fill(0) });

// `s.velocity` is the signal the site PUBLISHES — already through the
// provider's own 0.2 lerp. There is no way to recover the bare per-frame delta
// from here without duplicating the loop, so the honest thing to plot is that
// published signal against a SECOND smoothing pass at the reader's factor:
// the slider then shows what another filter of that strength costs in lag,
// and 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;
The demo reads the same value off the same tick as the six above. Gains across those six span more than a factor of four hundred, all tuned by eye against one quantity — which is the argument for producing that quantity once.
Scroll · GSAP ·
← Back to the lab
Creative Web Designer & Developer25.2048°N · 55.2708°E

AlexanderSmith

Locating the studio25.2048°N · 55.2708°E