Interaction ProTip #1: Stick vs. Ease

Sometimes you’ll need to build a widget or component that responds directly to user input, like a dial or something draggable. There should be a tangible relation between the user’s movement and the UI’s reaction, so normally you will want things to “stick” to the user’s finger or cursor. If the animation is not in response to direct interaction you will want to ease, and there’s a very neat little way to do the easing in code.

Let’s take a little widget that sticks to your finger. When you drag it we want it to follow your finger, and when you release it it’s going to ease back to it’s original location. Simple enough.

You can take a look at the code in more detail if you like to get stuck right in, and there’s also a standalone version of the demo if you don’t like reading gists.

So here’s how you make it work as well as possible:

1. Track from an absolute start position

You’ll see when the user starts a drag motion that the first thing you should do is extract a base position and a separate value for how far they’ve moved from that base position:

function onDown (evt) {

  // set up the tracking so we know
  // how far the user has moved
  userIsDragging = true;
  setUserBasePosition(evt);
  setPositionDifference(evt);

  // now we know they're interacting
  // bind the callbacks
  document.addEventListener('mousemove', onMove);
  document.addEventListener('mouseup', onUp);
  document.addEventListener('touchmove', onMove);
  document.addEventListener('touchend', onUp);

  evt.preventDefault();
}

The code for the two functions (setUserBasePosition and setPositionDifference) are slightly convoluted just because we’ll need to listen for both touch events and mouse events. Essentially, though, we’ll simply extract the values and store them for later.

The reason we track absolutely is because the alternative is to only use deltas (the difference between the last position and the current one) and errors will creep in, what with JavaScript’s storage of floating point numbers.

2. Debounce your visual updates

I’ve said it before on lots of occasions, but it’s worth saying again: we want to debounce our visual updates to the next animation frame. This is because we will likely get several callbacks inside a single frame, but we only want to update our element once. As I said, we store the values inside the down, up and move callbacks, and we have a requestAnimationFrame callback running that handles the visual update to the box.

Our animation frame callback looks like this:

function onAnimationFrame () {

  var x = 0;
  var y = 0;

  if (userIsDragging) {

    // map to the user's cursor / finger
    x = boxPosition.x + differenceFromUserBasePositionToCurrent.x;
    y = boxPosition.y + differenceFromUserBasePositionToCurrent.y;

  } else {

    // ease the box back to its base position
    boxPosition.x += (0 - boxPosition.x) / 5;
    boxPosition.y += (0 - boxPosition.y) / 5;

    x = boxPosition.x;
    y = boxPosition.y;

  }

  // totally collapsed out the vendor prefixed nonsense...
  draggable.style.transform = 'translate(' + x + 'px, ' + y + 'px)';
  requestAnimationFrame(onAnimationFrame);

}

You’ll see we have two “modes” here, which to be fair I should probably break this out to separate functions. Ah well.

In any case the user is either dragging the box, or they aren’t. If they are we derive our box’s x and y values from the box’s base position plus the difference between where they are and where they started. If they aren’t dragging the box then we update the box’s position to the last known position, and we ease it back to (0, 0), which is pretty convenient because we’re using transforms.

3. Use a simple ease

When the user stops dragging we update the box’s position to be the its base position plus the difference:

function onUp (evt) {
  userIsDragging = false;

  setBoxPositionForEasingBackToHome();

  // reset the user drag values
  setUserBasePosition(evt);
  setPositionDifference(evt);
}

function setBoxPositionForEasingBackToHome () {
  boxPosition.x = boxPosition.x + differenceFromUserBasePositionToCurrent.x;
  boxPosition.y = boxPosition.y + differenceFromUserBasePositionToCurrent.y;
}

That means that when the user stops dragging we “lock in” that last known position and then in the animation frame callback we simply ease the values back to (0, 0):

boxPosition.x += (0 - boxPosition.x) / 5;
boxPosition.y += (0 - boxPosition.y) / 5;

Here’s the fun bit: any property you want to ease to another value can follow this pattern:

currentPropertyValue += (targetPropertyValue - currentPropertyValue) / easeStrength;

At first glance it’s possibly a bit odd, but let’s dissect it pretty quickly. We use a delta (+=) calculated on the difference between where we want to be and where we are (targetPropertyValue - currentPropertyValue) divided by an easing strength value. If the easeStrength were 1 we would simply add the difference and our box would snap to (0, 0). If easeStrength has a value greater than 1, however, we only move a proportion of the difference each time. On each subsequent frame the difference gets smaller, and that means we move a little less, giving the illusion of a slowdown.

Let’s quickly look at through some example numbers. Assume a box moving from 0 to 100 pixels across the screen:

  1. box.x += (100 - 0) / 10;
  2. box.x += (100 - 10) / 10;
  3. box.x += (100 - 19) / 10;
  4. box.x += (100 - 27.1) / 10;

… and on and on. Each time the difference between where the box is and where we want it to be is decreasing, and that means we appear to slow down as we get closer to the target value.

The legendary Paul Neave also wrote about this technique on his blog a while back. I’m guessing all us old Flash folk know about it, and probably don’t want it get lost in the sands of time given its usefulness!

It’s worth mentioning that in this particular demo CSS transitions would allow us to get back to (0, 0). Where you can you should consider using CSS transitions because the compositor can typically handle animations very efficiently. But for the times you can’t get by without JavaScript, or you’re changing a property that you can’t use CSS to handle, this approach will help.

4. Use transforms and layers to your advantage

If you didn’t notice it the first time around, have another look at the end of the onAnimationFrame callback:

draggable.style.transform = 'translate(' + x + 'px, ' + y + 'px)';

So we use a transform to move the element around, but also note that a 2D transform doesn’t qualify an element for it’s own compositor layer in Blink / WebKit, so we should really tack on some styling to let the compositor know that we want to promote this element. In the future we will have will-change, but since we don’t have that just yet we can just apply a 3D transform:

-webkit-backface-visibility: hidden;

You may be wondering why I’m not using translateZ(0), and the answer is pretty simple: if I do then I’ll have to include it whenever I update the element’s x and y positions. Without it as soon as I update the transform the layer promotion would be lost. With -webkit-backface-visibility it remains, and is independent of the transforms.

5. Bind low, bind late, unbind ASAP

Last but not least take a look again at the latter half of the onDown function:

// now we know they're interacting
// bind the callbacks
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.addEventListener('touchmove', onMove);
document.addEventListener('touchend', onUp);

The key here is that we don’t have unnecessary callbacks bound to the document until we know for sure that we need them. To have the callbacks running all the time, even when the draggable widget isn’t active, causes us to make a trip over to the main thread from the compositor to have JavaScript run. We should only do that when we absolutely know we need to execute that JavaScript. When we’re done, we can unbind the listeners and smile a happy smile, safe in the knowledge we didn’t run JavaScript unnecessarily.

Onwards and upwards

Now you know how you can set your interactions and easing up to feel as snappy and smooth as possible. I guess that was more like 5 tips in 1. Lucky you!