Part VI · Interaction 12 / 25

12 Direct manipulation 12 Direct manipulation 12 Direct manipulation 12 Direct manipulation

While you hold it, it stays under your finger. When you let go, it keeps your speed.

Springs (chapter 5) and momentum (chapter 6) have so far been played to you: something starts them and they run. Most motion in an interface is like that. Direct manipulation is played by you. You put a finger on a card and move it, and the card moves because you did. There is no curve and no duration. There is only your hand.

That makes a promise, and it is easy to break. While your finger is down, the thing stays under it: however far you move, it moves, at once. When you let go, it doesn’t stop and start again. It carries on at the speed your hand gave it. And whatever it is doing, you can take hold of it again.

Letting go

A finger drags a card most of the way along its track, speeding up, and lets go. Then a spring takes the card the rest of the way. In the top row the spring starts at the finger’s speed. In the bottom row it starts from rest. Every ghost is one frame.

speed kept0579101112131415161718192021222324252748speed dropped05791011121314151617181920212324252627293148
00/60 fr
Fig. 12.1 — One drag, two releases

Until the finger lifts, the rows are the same: the ghosts spread out as the hand speeds up. Then they part. The top row carries straight on and closes up gently into place. In the bottom row the gaps collapse at the point of release, open up again, and close again. For a moment the card nearly stopped, though nothing stopped it. The hand was moving, and the card forgot.

Throw it

Now do it yourself. Drag the card slowly: the spot you took hold of stays under your finger. Pull it past either end. Then fling it and let go, first with Keep the velocity on, then with it off.

Drag the card, fling it, pull it past the edges, catch it mid-flight.
Fig. 12.2 — A card you can hold, throw, and catch

With the velocity kept, the throw and the settle are one motion. Without it, the card stops dead as your finger lifts, then sets off again on its own, as if it slipped out of your hand. Last, throw it and grab it before it lands. It stops under your finger, wherever it is.

Under the finger

The first half of the promise has no animation in it at all. On every pointer event, the card moves by exactly as much as the pointer did. Three common mistakes break it:

  • A transition on transform. Every move starts a short tween toward the finger, so the card is always catching up. It trails the finger, and the faster you move, the further behind it falls.
  • Smoothing. x += (finger − x) · 0.2 on each frame looks smooth, and it puts the card behind the finger on purpose. Your hand is already smooth. Anything you add is lag.
  • Centring on the pointer. Take hold of a card near its edge and it jumps to put its middle under your finger. Remember the offset between the pointer and the card at pointerdown, and keep it for the whole drag.

On the web, Pointer Events give one set of events for mouse, pen and touch: pointerdown, pointermove, pointerup. Two more details make them work for dragging:

  • Pointer capture. Call setPointerCapture(e.pointerId) in pointerdown, and every later event from that pointer comes to your element, even when the pointer is no longer over it. A fast mouse can leave the card behind between two events. Without capture, the moves stop arriving, and a pointerup somewhere else is never heard. (Touch pointers are captured to their element implicitly; mouse and pen are not.)
  • touch-action: none. On a touch screen the browser wants a moving finger for itself, to scroll or zoom. When it takes over, it sends pointercancel and your moves stop. touch-action: none on the card tells it not to. If the card only moves sideways, touch-action: pan-y still lets vertical swipes scroll the page.

Past the edge

A card on a track has ends. When the finger goes past one, the card can do one of three things:

  • Stop at the edge. It’s a wall. The card leaves your finger behind, and the promise is broken.
  • Keep following 1:1. The edge means nothing, and the card goes wherever you drag it.
  • Rubber-band. It keeps following, but less and less the further you pull.

The rubber band is the honest one. The card never stops moving while your finger moves, so it never feels as if you lost your grip. But every extra pixel costs more, and that resistance says: there is nothing more this way. The usual way to imitate the iOS rubber band is one line:

pulled = (1 − 1 / (x · c / d + 1)) · d

Here x is how far the finger is past the edge, d is the size of the thing (the track’s length, a list’s height), and c is a constant, about 0.55. Right at the edge the card moves at c times the finger’s speed, about half. Further out that share keeps falling. And however far you pull, the card never gets d past the edge. Try one row by hand: a finger 100px past the edge, on a 400px track. x · c / d is 100 × 0.55 ÷ 400 = 0.1375, so 1 − 1 / 1.1375 is 0.121, and times d that is 48px. The card is 48px past the edge while the finger is 100px past. For a track 400px long:

Finger past the edgeCard past the edgeShare
25px13px53%
100px48px48%
200px86px43%
400px142px35%
800px210px26%

Figure 12.2 uses the width of its track as d. (c is a taste value; 0.55 is the usual imitation of iOS. Try 0.3 for a stiffer edge and 0.8 for a looser one.) Switch Rubber-band off and pull past an end: the card stops dead at the edge and your finger slides on without it.

Let go: keep the velocity

The second half of the promise is the handoff. At the moment of release the card is moving, and whatever happens next has to start at that speed. It takes three steps, each from an earlier chapter:

  1. Measure. The release velocity is the slope of the last 100ms of pointer positions (chapter 6). Older samples describe a different gesture.
  2. Project and choose. Where would the throw stop, with friction? At x + v / λ (chapter 6), where λ is how fast friction bleeds off speed, per second. Figure 12.2 uses iOS’s normal friction, which makes λ about 2 per second, so a release at 1000 px/s lands about 1000 ÷ 2 = 500px further on. The stop nearest that landing is the target: left, middle or right.
  3. Carry. A spring takes the card to the target, starting at the release velocity (chapter 5). Its first position and speed are the card’s last position and speed under the finger, so there is no seam.

After every throw, the readout under Figure 12.2 shows the release velocity, the projected landing and the stop it chose. Turn off Project the landing and make a short, quick flick: the card goes back to the stop nearest where you let go, as if the flick didn’t count.

Now throw it toward the middle, but gently. The projection falls short and picks the stop the card started from. The spring starts out moving the way you threw it, slows, turns, and comes back. That turn is honest: the card really was moving that way, and the spring spends the speed before it returns.

A spring that starts with the finger’s speed can overshoot with very little bounce. That overshoot is the hand’s momentum, not the spring’s (chapter 5, Figure 5.4), so a release spring should be well damped. The one in Figure 12.2 has response 0.35 and bounce 0.1.

Catch it

Interruptible means the card never makes you wait. Put your finger on it while it’s settling and it stops, right there, under your finger. The spring is simply dropped. Its whole state was a position and a velocity, and the finger now owns both. There’s nothing to finish, cancel or reverse.

  • Never ignore input while something moves. Don’t lock the drag until the spring settles. The moment someone wants to change their mind is exactly when the thing is moving.
  • Stop where it is, not where it was going. Jumping to the target first is a teleport. With the Web Animations API, call commitStyles() and then cancel(): the element keeps the position it had.
  • Take hold from where it is. Measure the grab offset from the card’s position now, so the catch doesn’t jump. If it’s past an edge, undo the rubber band first to find where the finger would be.
  • Start a new gesture. Clear the velocity samples. The throw that sent it is over.

Tune the release

This is the whole chapter in one small program: a puck on a track with three stops, dragged 1:1, rubber-banded past the ends, projected and sprung on release. The design is in the constants at the top. Change one at a time, and throw after each change.

  • Set RUBBER to 0. Pull past an end: the edge is a wall, and the puck leaves your finger. Set it to 1: the edge barely resists. Find the value where the edge feels like resistance, not a wall.
  • Set KEEP_VELOCITY to false and fling. Watch the puck stall where you let go.
  • Set BOUNCE to 0.4. A hard throw now swings past its stop. Is that the throw, or the spring?
L7 Canvas sandbox
Open in Lab
Start from
Refresh rate
dt 0.0ms fps 0 frames 0
00/60 fr 0ms
Drag, rubber-band, release · JS edits apply live · state kept

The notation

CSS can’t do the dragging, because it never learns where the finger is. Its part is to get out of the way. Everything else is JavaScript: Pointer Events for the drag, a spring for the release.

12 · Direct manipulation: a card that can be dragged Open in Lab
.track {
  position: relative;
}

.card {
  position: absolute;
  left: 0;
  touch-action: none;        /* a finger drags the card; the page doesn't pan or zoom */
  -webkit-user-select: none;
  user-select: none;         /* a drag never selects text */
  cursor: grab;
}

/* If the card only moves sideways, let vertical swipes still scroll the page:
   touch-action: pan-y; */

.card.is-dragging {
  cursor: grabbing;
}

/* `position: absolute; left: 0` puts the card at the track's start;
   JS then moves it with transform, never with left. No transition on
   transform. JS writes it on every pointer move and every
   spring frame; a transition would leave the card behind the finger. */
12 · Direct manipulation: 1:1, rubber-band, velocity Open in Lab
// Drag a card along its track: 1:1, with pointer capture.
// stop() and release() are in the next card. Read the handlers at the
// bottom first (pointerdown, pointermove, pointerup), then the helpers.
const card = document.querySelector('.card');
const C = 0.55;            // rubber-band constant, iOS-like
let x = 0;                 // the card's offset, px
let min = 0, max = 0;      // the ends of its travel
let grab = 0;              // pointer minus card, kept for the whole drag
const samples = [];        // positions from the last 100ms

// The only place the card is moved: set x, then draw it there.
function moveTo(to) {
  x = to;
  card.style.transform = `translateX(${x}px)`;
}

// Past an edge it still follows, less and less: (1 − 1 / (x·c/d + 1))·d.
const band = (past, d) =>
  Math.sign(past) * (1 - 1 / ((Math.abs(past) * C) / d + 1)) * d;

// The inverse: how far past the edge the finger is, for the card to sit
// here. Needed when you catch a card that is already banded past an edge:
// without it, grab would be measured from the wrong spot and the card
// would jump the moment you touched it.
const unband = (past, d) => {
  const p = Math.min(Math.abs(past), d * 0.99);
  return (Math.sign(past) * p * d) / (C * (d - p));
};

// limit: where the finger says the card should be (raw), turned into
// where it is drawn: unchanged inside min..max, banded outside.
function limit(raw) {
  const d = max - min;
  if (raw < min) return min + band(raw - min, d);
  if (raw > max) return max + band(raw - max, d);
  return raw;
}

// unlimit: the reverse, from where the card is drawn to where the finger is.
function unlimit(pos) {
  const d = max - min;
  if (pos < min) return min + unband(pos - min, d);
  if (pos > max) return max + unband(pos - max, d);
  return pos;
}

// Record the card's position and the time in ms, and drop anything
// older than 100ms: only the end of the gesture says how fast it was.
function sample(t) {
  samples.push({ x, t });
  while (samples.length > 2 && t - samples[0].t > 100) samples.shift();
}

// Px per second: the least-squares slope of the samples (chapter 6).
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;
}

// 1. pointerdown: the finger lands. Do these in order.
card.addEventListener('pointerdown', (e) => {
  stop();                                // catch it mid-flight, where it is
  card.setPointerCapture(e.pointerId);   // every move comes here, even off the card
  card.classList.add('is-dragging');
  max = card.parentElement.clientWidth - card.offsetWidth; // furthest right it may sit
  // grab = the gap between finger and card. Subtracting it on every move
  // keeps the spot you touched under your finger. unlimit(x) rather than
  // x, so a card caught past an edge is measured from where the finger
  // would be.
  grab = e.clientX - unlimit(x);         // keep the spot you took hold of
  samples.length = 0;
  sample(e.timeStamp);
});

// 2. pointermove: on each event, put the card where the finger says.
card.addEventListener('pointermove', (e) => {
  if (!card.hasPointerCapture(e.pointerId)) return; // not dragging: ignore hovers
  moveTo(limit(e.clientX - grab));       // 1:1: no easing, no smoothing
  sample(e.timeStamp);
});

// 3. pointerup: the finger lifts, and release() (next card) hands off to
// the spring. pointercancel is the browser taking the gesture away
// (a call, a system swipe): treat it as a lift, so the card never sticks.
card.addEventListener('pointerup', release);
card.addEventListener('pointercancel', release);
12 · Direct manipulation: release into a spring Open in Lab
// Uses card, x, min, max, moveTo(), sample() and releaseVelocity() from the card above.
const lambda = -Math.log(0.998) * 1000;            // friction, to project (chapter 6)
const response = 0.35, bounce = 0.1;               // the release spring (chapter 5)
const k = ((2 * Math.PI) / response) ** 2;         // stiffness, for mass 1
const c = (4 * Math.PI * (1 - bounce)) / response; // damping
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
let raf = 0;

function stop() {
  cancelAnimationFrame(raf); // no more frames: x and v stay as they were
}

// The three steps of the handoff from the prose: measure, project and
// choose, carry. e.timeStamp is the event's time in ms.
function release(e) {
  if (!card.hasPointerCapture(e.pointerId)) return;
  card.classList.remove('is-dragging');
  sample(e.timeStamp);                       // held still before lifting? then no speed
  const v = releaseVelocity();               // px/s, from the last 100ms
  const landing = x + v / lambda;            // where the throw would stop
  const stops = [min, (min + max) / 2, max];
  const target = stops.reduce((a, b) =>
    Math.abs(b - landing) < Math.abs(a - landing) ? b : a);
  if (reduce.matches) return moveTo(target); // the drag was theirs; skip the settle
  settle(target, v, e.timeStamp);
}

// A spring that starts at the finger's speed: semi-implicit Euler, a step per frame.
// target: where it ends (px). v: its speed now (px/s). t0: the time it starts (ms).
function settle(target, v, t0) {
  let last = t0;
  const frame = (now) => {
    // Seconds since the last frame, kept between 0 and 32ms so a stalled
    // tab can't hand the spring one huge step that flings the card away.
    const dt = Math.min(Math.max(now - last, 0), 32) / 1000;
    last = now;
    v += (-k * (x - target) - c * v) * dt;   // the pull, minus friction
    moveTo(x + v * dt);
    // Within half a pixel and slower than 5 px/s: close enough, so snap
    // and stop, instead of running the spring for ever.
    if (Math.abs(target - x) < 0.5 && Math.abs(v) < 5) moveTo(target);
    else raf = requestAnimationFrame(frame);
  };
  raf = requestAnimationFrame(frame);
}
12 · Direct manipulation: the same release in Motion Open in Lab
import { animate } from 'motion';

// Replaces stop() and settle() from the card above.
// Motion takes velocity in px per second. Give it stiffness and damping:
// a spring set by duration or bounce may ignore `velocity`.
let controls = null;

function stop() {
  controls?.stop();       // it stops where it is, and x already holds that
}

function settle(target, v) {
  controls = animate(x, target, {
    type: 'spring',
    stiffness: 322.3,     // response 0.35, bounce 0.1, mass 1
    damping: 32.31,
    velocity: v,
    onUpdate: moveTo,
  });
}

The finger here drags a card along a track. In chapter 13 the same hand drags the page itself, and the question is what should move with it, and what should not.

Blind A/B

Two versions of the same change. Pick the one that feels right, then say why in one word. Ask of each one: does it feel like something a hand could hold?

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