Part II · Physics 06 / 25

06 Momentum and friction 06 Momentum and friction 06 Momentum and friction 06 Momentum and friction

A thrown thing keeps its speed and loses it slowly, so where it stops is known the moment you let go.

Chapter 5 ended with a spring that can start already moving, carrying the speed of a finger, but a spring cannot say where a fast flick should end up. This chapter is about that speed: where it comes from, and where it takes the thing.

Drag a list and let go, and it keeps moving. Nothing pushes it after your finger leaves the glass. It carries the speed you gave it, and friction takes that speed away a little at a time until it comes to rest. That is momentum and friction, and it is why a good scroll feels like something you threw rather than something you moved.

Friction has a useful habit: it always takes the same share of what is left, say 1% of the speed every instant. That makes the stop predictable. The moment you let go, the whole glide is already decided, and an interface can use that to choose where things go before they get there.

One throw, three frictions

Three dots leave the hand at the same speed. Hover the figure to see every frame as a ghost, and compare how quickly the gaps between ghosts shrink in each row. Only the friction differs.

no friction0123456789101112131415161718192021222324252627282930light friction01234681012141618202224273033364044495460687995138heavy friction0135828
00/60 fr
Fig. 6.1 — One throw, three frictions
  • No friction: even gaps. Nothing takes its speed away, so it would sail off the page.
  • Light friction (iOS’s normal, explained below): each gap is a little shorter than the one before. It coasts a long way and eases to rest.
  • Heavy friction (iOS’s fast): each gap is much shorter than the one before. The same throw is spent almost at once.

The first gap is the same in every row, because the throw was the same. After that, friction alone decides how far each dot goes.

Which one was thrown?

Feel

Someone flicked the dot. Which one feels thrown?

Friction takes a share

Friction doesn’t remove a fixed amount of speed, which would stop the thing at a fixed moment and feel like braking. It removes a fixed share, so as the speed falls, so does the amount removed, and the motion eases out. iOS writes this as a deceleration rate r: every millisecond, a scrolling list keeps r of the speed it had. The default is 0.998, so each millisecond it keeps 99.8%. Over one 60Hz frame (about 16.7ms) that compounds to 96.7%, because 0.998 multiplied by itself 16.7 times is 0.967. That is why, at 60 frames a second, each gap in the light-friction row of Figure 6.1 is about 0.967 of the one before. The heavy-friction row uses the other rate, 0.99, and keeps 84.6% per frame. Those two rows are iOS’s normal and fast.

Taking the same share every moment is exponential decay. To write it as one formula, turn the rate into a friction per second, λ = −ln(r) × 1000: ln is the natural logarithm (Math.log), and the × 1000 converts per millisecond to per second. The speed after t seconds is

v(t) = v0 · e−λt

For normal, λ is about 2 per second; for fast, about 10. The speed never quite reaches zero, but it shrinks quickly: at normal it halves every 350ms or so, at fast every 70ms. That long, thinning tail is the glide. In code, Math.exp(-lambda * t) is the e^(−λt) factor: 1 at release, falling toward 0.

Where it will land

Add up every step of a decay and the total is short enough to do in your head:

landing = x0 + v0 / λ

Why that sum is so short: add up the distance covered at every moment of the decay and the total is the release speed divided by λ. Another way to see it: the glide covers exactly as much ground as the release speed held constant for 1/λ seconds (half a second at normal), measured from wherever you let go. Released at 1000 px/s with normal friction, it travels about 1000 ÷ 2 = 500px. With fast, about 100px. It is known the moment the finger lifts, before a single frame of the glide is drawn.

Deceleration rate
000/480 fr 0ms
Open in Canvas sandbox
Fig. 6.2 — Flick with friction: the landing is known at release

The control above the figure sets the deceleration rate. Drag the puck and let go. The blue ring appears at once, at x0 + v0 / λ, and the puck glides into it. Switch to Fast and throw the same way: the same hand, a fifth of the distance. When nobody is throwing, the figure throws for itself.

You will also meet the landing in Apple’s form, which is the same thing counted millisecond by millisecond: the distance in the first millisecond is v / 1000, and each millisecond after it covers r times the one before. A sum where every term is r times the last equals the first term times r / (1 − r), so the total is (v / 1000) · r / (1 − r). For these two rates it agrees with v0 / λ to within about half a percent.

The landing picks the target

Because you know where a throw will stop, you can change where it stops. A carousel shouldn’t come to rest between two pages. So:

  1. Project. Work out the landing: x0 + v0 / λ.
  2. Choose. Pick the page, card, corner or detent (a resting place, like a click stop on a dial) nearest that point.
  3. Carry. Glide there. A decay stops exactly on the target if it leaves at v = (target − x0) · λ: that is the landing formula solved for v. Or hand the release speed to a spring (chapter 5), which keeps the hand’s velocity and settles on the target.

Choose by the landing, not by the finger. A quick, short flick often lifts before the finger has crossed halfway. Snap to the page nearest the finger and the content slides back, as if the flick was ignored. The projection sees where it was heading and turns the page. A slow drag past halfway, set down with no speed, still turns the page too, because its landing is where it already is.

Flick-to-dismiss makes the same choice. A sheet thrown down hard projects past its dismiss line and goes, even if the finger only moved a little. A sheet nudged and let go gently lands where it is, and settles back.

Measure the throw, not the drag

Everything above depends on one number, the release speed, so measure it carefully. A drag can last seconds. It speeds up, slows down, pauses. Its average speed describes the whole trip, not the flick at the end. Only the last moments say where the finger was heading when it let go.

So keep a short window of pointer samples, about the last 100ms, and fit a straight line through them. The slope of that line is the release velocity. A least-squares fit (the straight line that lies closest to all the points) is steadier than subtracting the first sample from the last, because one late or jittery event can’t swing it much. The 100ms is a taste value: shorter reacts to jitter, longer averages in the slowing-down before the lift.

If the finger stops before it lifts, the window holds only still samples, the velocity is zero, and nothing is thrown. That’s right: a finger that stops has put the thing down.

Normal and fast

normalfast
Speed kept per ms0.9980.99
Speed kept per 60Hz frame96.7%84.6%
Friction λ, per secondabout 2about 10
Speed halves everyabout 350msabout 70ms
A 1000 px/s flick travelsabout 500pxabout 100px
99% of the way after2.3s0.46s

Read the table this way: the same hand at 1000 px/s, and only λ changes. The last row uses ln(100) ≈ 4.6: after 4.6 / λ seconds, 99% of the distance is covered.

  • Normal glides. It feels loose and light. The content carries on well after your finger has gone, and a hard flick crosses a lot of ground. It suits long lists and feeds, where a throw is about getting far.
  • Fast stops. It feels firm and controlled. The content stays near where you let go. It suits pickers, pages and sheets, where a throw is about direction and every resting place matters.

The same hand, five times the distance. Pick the rate by what the content is for.

Choose a page by throwing

Flick the puck along the five pages. (Left alone, it throws itself at 900 px/s.) The blue ring is where the throw would stop, and the thick blue tick is the page it goes to instead. With RATE at 0.998, a light flick crosses several pages. Change it to 0.99 and throw the same way: now a flick moves about one page. Find the rate where one ordinary flick is one page.

Then, in release(), change s.natural = s.x + v / lambda to s.natural = s.x, the page nearest the finger. Flick quickly and briefly. The puck slides back. That is the flick being ignored, and the reason to choose by the landing.

L7 Canvas sandbox
Open in Lab
Start from
Refresh rate
dt 0.0ms fps 0 frames 0
00/60 fr 0ms
Flick to a page · JS edits apply live · state kept

The notation

CSS can’t simulate friction. A transition only knows a start, an end and a duration, and it can’t react to a throw. But every decay has the same shape, stretched in time, so once JS has worked out where the throw lands, CSS can replay the glide. A long ease-out is the rough version; linear() sampled from 1 − e−λt is close to exact, and cubic-bezier(.18, .8, .4, 1) stays within 1% of it.

In JS the glide is the real thing. Per frame (dt is the seconds since the last frame), it is roughly v *= Math.exp(-lambda * dt) then x += v * dt, but those steps add up to a little short of the projection, and more so at low frame rates. Figure 6.2 moves each frame by the exact distance, v / lambda * (1 - Math.exp(-lambda * dt)), before it takes the speed down. The cards below compute position from the time since release instead. Both end exactly on the projection at any frame rate.

06 · Friction: a decay, replayed in CSS Open in Lab
/* Every decay is one curve, 1 − e^(−λt), stretched in time.
   Each number is how far along the glide the thing is (0 to 1); stops
   without a % are spread evenly, one every 5%. The curve is scaled
   to end on the landing, when 99% of the speed is gone.
   Only the duration changes, 4.6 / λ: 2300ms at 0.998, 460ms at 0.99.
   At release, JS clears the drag transform, sets --landing
   (x0 + v0 / λ, in px) and adds .is-thrown. */
.strip.is-thrown {
  transform: translateX(var(--landing));
  transition: transform 460ms linear(
    0, 0.208, 0.373, 0.504, 0.608, 0.691, 0.756, 0.809, 0.85, 0.883, 0.909,
    0.93, 0.946, 0.959, 0.97, 0.978, 0.985, 0.99, 0.994, 0.997, 1
  );
}

/* Within 1% of the same curve, as a bezier:
   transition-timing-function: cubic-bezier(.18, .8, .4, 1); */
06 · Momentum: release velocity from the last 100ms Open in Lab
// Only the last 100ms of a drag say where it was heading.
const samples = [];

function track(x, t) {
  samples.push({ x, t });
  while (samples.length > 2 && t - samples[0].t > 100) samples.shift();
}

// Px per second: the slope of a least-squares line through the samples.
function releaseVelocity() {
  const n = samples.length;
  if (n < 2) return 0;
  let st = 0, sx = 0, stt = 0, stx = 0;
  for (const s of samples) {
    const t = (s.t - samples[0].t) / 1000;
    st += t; sx += s.x; stt += t * t; stx += t * s.x;
  }
  const d = n * stt - st * st;
  return d === 0 ? 0 : (n * stx - st * sx) / d;
}
06 · Momentum: throw, then glide with friction Open in Lab
// Uses track() and releaseVelocity() from the card above.
const el = document.querySelector('.strip');
const rate = 0.998;                     // kept per ms: 0.998 normal, 0.99 fast
const lambda = -Math.log(rate) * 1000;  // the same friction, per second
let x = 0, grab = 0, raf = 0;

function moveTo(to) {
  x = to;
  el.style.transform = `translateX(${x}px)`;
}

function glide(v0) {
  const x0 = x, start = performance.now();
  const landing = x0 + v0 / lambda;     // known the moment you let go
  const frame = (now) => {
    const t = Math.max(0, now - start) / 1000;
    // the distance still to go shrinks by e^(-lambda * t)
    moveTo(landing - (landing - x0) * Math.exp(-lambda * t));
    if (Math.abs(landing - x) > 0.5) raf = requestAnimationFrame(frame);
    else moveTo(landing);   // within half a pixel: snap and stop
  };
  raf = requestAnimationFrame(frame);
}

el.style.touchAction = 'none';          // drags move the strip, not the page
el.addEventListener('pointerdown', (e) => {
  cancelAnimationFrame(raf);            // catch it mid-glide
  el.setPointerCapture(e.pointerId);
  samples.length = 0;
  grab = e.clientX - x;
  track(e.clientX, e.timeStamp);
});
el.addEventListener('pointermove', (e) => {
  if (!el.hasPointerCapture(e.pointerId)) return;
  moveTo(e.clientX - grab);
  track(e.clientX, e.timeStamp);
});
el.addEventListener('pointerup', (e) => {
  if (!el.hasPointerCapture(e.pointerId)) return;
  track(e.clientX, e.timeStamp);
  glide(releaseVelocity());
});
06 · Momentum: the landing picks the page Open in Lab
// Uses rate, lambda, x and glide() from the cards above.
// Apple's projection (WWDC 2018): the same landing, summed per millisecond.
const project = (v) => ((v / 1000) * rate) / (1 - rate);

function snapToPage(v, pageWidth, pageCount) {
  const landing = x + project(v);          // where the throw would stop
  // x is negative when the strip has slid left, so flip the sign to count pages
  const nearest = Math.round(-landing / pageWidth);
  const page = Math.min(pageCount - 1, Math.max(0, nearest));
  const target = -page * pageWidth;        // the strip slides left
  glide((target - x) * lambda);            // the speed that stops there
}

// In pointerup, instead of glide(releaseVelocity()):
//   const pageWidth = el.parentElement.clientWidth;
//   snapToPage(releaseVelocity(), pageWidth, el.children.length);

You now have timing, spacing, weight, springs and friction: the physics. Animators wrote down rules for all of it decades before this maths existed. Chapter 7 asks which of their twelve principles you have already met, and where each one lives in an interface.

Blind A/B

Two versions of the same change. Pick the one that feels right, then say why in one word. Watch the endings: does it run out of speed, or hit a wall?

Eye trainer · choose Blind A/B

Two versions. Which is better? Then say why, in one word.Taste: judging, then naming the reason.

5 trials. Judge with your eyes first; the numbers come after. Three right in a row makes trials harder, a miss eases them.

Full rounds and your calibration in the Eye trainer