Constrain motion

When creating UIs, there’s plenty of instances when we want to constrain motion to within a specific range.

For instance, if we’re making an input slider, we want to constrain its sliding motion to within the slider’s visible boundaries.

Otherwise, the illusion of a coherent piece of UI is broken, and the input is useless:

In this tutorial, we’ll look at a functional approach to solving this problem. We’ll handle out-of-range motion with 1) a hard clamp, and 2) a static spring.

You can follow along by forking this CodePen.

Clamp

The easiest way to restrict motion to within a range is by clamping it. Clamping is simply the process of restricting a number within a range.

JavaScript includes two commonly-used clamping functions, Math.min and Math.max.

We can change the dragging action from:

pointerX(handleX.get())
  .start(handleX)

To:

pointerX(handleX.get())
  .pipe(v => Math.max(0, v))
  .start(handleX)

By using Math.max to clamp the lower range to 0, you’ll notice you can’t drag the handle beyond the left boundary:

We could add the right boundary like this:

pointerX(handleX.get())
  .pipe(
    v => Math.max(0, v),
    v => Math.min(250, v)
  )
  .start(handleX)

Popmotion provides a clearer way of expressing this via the clamp transformer. This function accepts a range and returns a function that will clamp any provided number to within that range:

clamp(0, 250)(-50) // 0

So our pointerX function becomes:

pointerX(handleX.get())
  .pipe(clamp(0, 250))
  .start(handleX)

We now have a functional input slider! But we don’t have a delightful one.

Think of iOS, when you scroll a view beyond its boundaries. It doesn’t stop dead, it tugs back. It’s a visceral and satisfying experience.

We can replicate this experience with Popmotion using static springs.

Static springs

Popmotion has so many springs you could use it as a mattress. It’s not a point of pride, it can be confusing. But, different springs are useful in different situations:

  • Spring is a highly-accurate simulation, but immutable. Most appropriate for spring animations.
  • Physics is a lightweight integrated simulation that can change over time. Most appropriate for spring interactions.

Of the two, physics, in theory, could be used to restrain motion to a range. But it’s overkill. You’d have to set conditional statements to start and stop animations, grab the velocity from handleX, set this and that. It’d be an imperative soup.

Instead, Popmotion provides two transformers that can be used in a purely functional manner: linearSpring and nonlinearSpring.

They both share an API. Provide a strength and a target, and they’ll return a function. This function, when given a numerical value, will return a new number “pulled” towards the target.

linearSpring(0.25, 0)(4) // 1

linearSpring applies a constant force, whereas nonlinearSpring applies a greater force the further the given number is away from target.

In tandem with the conditional transformer, we can apply these springs only when the number output by pointerX exceeds the defined boundaries:

conditional(
  v => v < 0, // If less than 0
  linearSpring(0.25, 0) // Apply spring
)

Let’s compose a new function using pipe, conditional and linearSpring that will restrict a range of motion using springs:

const springRange = (from, to, strength) => pipe(
  conditional(
    v => v < from,
    linearSpring(strength, from)
  ),
  conditional(
    v => v > to,
    linearSpring(strength, to)
  )
);

This can be passed to our pipe:

pointerX(handleX.get())
  .pipe(springRange(0, 200, 0.2))
  .start(handleX)

To create spring-restricted motion that feels like this:

Try replacing linearSpring with nonlinearSpring and adjust the strength to see how that changes the behaviour of the handle.

Spring back

You’ll notice that if you release the handle outside the slider’s boundaries, it just sits still. This is at odds with the perceived spring that binds the handle to the slider.

For this, we can start a spring animation on the mouseup/touchend event.

Currently, we just call () => handleX.stop() which ungracefully stops any action driving handleX, which in this example is pointerX.

Let’s replace this reaction with:

() => {
  const x = handleX.get()

  if (x < 0 || x > 250) {
    // Start spring animation
  } else {
    handleX.stop();
  }
}

Now the function will only stop motion abruptly if the handle is within the slider’s range.

To handle cases when the handle is outside the range, replace the commented line with this:

spring({
  from: x,
  to: x < 0 ? 0 : 250,
  velocity: handleX.getVelocity(),
  stiffness: 900,
  damping: 30
}).start(handleX);

You can play around with the settings of both the static spring and the spring animation until you find something that feels responsive, satisfying and in-keeping with your brand.

Conclusion

We can declaratively implement motion constraints using functional composition.

Clamping is the most basic approach, but static springs can yield more satisfying results when paired with user input.

pipe and conditional allow you the freedom to devise and compose your own strategies for imposing motion constraints.

A couple ideas for next steps:

  • Replace the stop call in our mouseup event with a decay animation that allows the user to throw the handle. It could include a little bump animation when it hits a slider limit.
  • Generate your own static springs using the generateStaticSpring transformer.