Today we’re going to explore the concept of input smoothing as an introduction to reactive animations. So what exactly do I mean by reactive animations? For this article, we’ll use the term to describe animations that have no set endpoint, but instead react to a steady stream of incoming values.
I find these animations particularly useful for components that require an animation layer on top of an interaction like dragging, scrolling, mousemove, and swiping. Specifically, in this article, we’ll focus on mousemove and those little circles you’ve seen following the cursor in certain eye-catching interactive sites like the ones seen below:
https://www.giacomomottin.com/
Prerequisites
So I’m certainly not a die-hard when it comes to functional programming, but I do think some concepts from it can be quite useful in certain places. In this tutorial we’ll make heavy use of factory functions to create an rx-like pattern. We may even dabble in some currying here and there as well.
If you’re not familiar with these concepts, MPJ of Fun Fun Function has a wonderful series on functional programming in JavaScript here, specifically talking about factory functions and currying.
The End Result
By the end of this article we’ll have a nice little circle smoothly following your cursor used like the example seen below:
const App = () => { const smoothedMouse = useMemo(() => { return smooth({x: 0, y: 0}).start(({ x, y }) => { document.body.style.setProperty(‘--mouse-x’, x); document.body.style.setProperty(‘--mouse-y’, y); }); }, []); const updateCursorPosition = e => { smoothedMouse.update({ x: e.clientX, y: e.clientY, }); } useEffect(() => () => smoothedMouse.stop(), []); return ( <div className=”app” onMouseMove={updateCursorPosition}> <div className=”cursor”> </div> );
Codepen Example: https://codepen.io/littlemilk/pen/ZgvJym
If you’re familiar with popmotion, you’ll notice the api for our smooth()
is quite similar. Matt Perry, the creator of popmotion, has some excellent blog posts and musings on animations as well at https://inventingwithmonster.io/
Overview
To create this smooth object we’re going to need three parts:
- The Request Animation Frame Loop — This loop will call some logic on every frame.
- The Scan — This object will scan over a series of values and save some information between iterations. We’ll also expose a
next()
method here that we can use to schedule the next iteration. - The Lerp — This will be the function we’ll pass into our scanner to compute the next step on every iteration.
The Request Animation Frame Loop
requestAnimationFrame()
, commonly abbreviated to rAF
, is the native method our browser provides to us to execute some logic when the next frame is rendered. We’re going to create an object that recursively calls this and executes some listener on every frame.
To start I always like to think about what methods we’ll need, then the necessary state we’ll need to store between those methods. First off, our rAF()
implementation will need a start()
method that allows us to add a listener and start listening on every iteration of the loop:
const rAF = () => { const start = listener => { // @TODO initiate loop } return { start } }
Next let’s create the loop()
method for internal use. This will be our point of recursion. To keep this method’s definition clean, let’s store our listener. I like to store necessary data, inside the closure, in a state object to make it clear where everything lives, so let’s do that!
const rAF = () => { const state = { listener: () => {} }; const loop = timeStamp => { state.listener(timeStamp); requestAnimationFrame(timeStamp => { loop(timeStamp) }); }; const start = listener => { state.listener = listener; loop(performance.now()); } return { start } }
As you can see, our loop function calls our listener, then requests itself to be called again on the next animation frame. For now we’re passing our listener a timeStamp
value. This isn’t relevant to this tutorial as we’re just going to be using rAF()
as a scheduler, but will be useful when we get into other methods like physics or tweens in other blog posts.
The only issue with our current implementation is that we have no method to stop this loop once it has started. If we subscribe to rAF()
with a component on mount, when the component unmounts, this loop will still be running. Let’s fix that by having start()
return a stop()
method:
const rAF = () => { const state = { listener: () => {}, animationFrameId: null }; const stop = () => { cancelAnimationFrame(state.animationFrameId); }; const loop = timeStamp => { state.listener(timeStamp); state.animationFrameId = requestAnimationFrame(timeStamp => { loop(timeStamp) }); }; const start = listener => { state.listener = listener; loop(performance.now()); return { stop }; } return { start }; }
As you can see, requestAnimationFrame()
leaves us with a frame id we can keep track of between calls. To escape the loop, we simply cancel the call to the next animation frame.
Now that we have a nice way to listen to the frames, let’s create our logic needed to execute on each frame.
The Scan
If you’re familiar with rxjs, you’ll most likely be familiar with the scan operator, but for those of us new to it, it simply allows us to scan over a series of incoming values and track some data between each new value.
Our implementation of scan()
will also expose a next()
method which will tell our scan when to take the next incoming value. Let’s take a look at what our final result will look like here:
const scanner = scan((accumulator, v) => { return accumulator += v; }, 0).start(v => { console.log(v); }); scanner.next(1) // console logs 1 scanner.next(1) // console logs 2 scanner.next(4) // console logs 6
Now that you’ve gotten a second to digest what our scan()
method does, let’s implement it! First we know that we need to provide a reducer and an initial value to scan()
, so let’s set that up:
const scan = (reducer, init) => { const state = { accumulator: init, reducer: reducer }; }
Next, we need to provide a start method that allows us to add a listener to when the accumulator calculates its next iteration:
const scan = (reducer, init) => { const state = { accumulator: init, reducer: reducer, listener: () => {} }; const start = listener => { state.listener = listener; }; return { start }; }
Now that we have a way to listen to each iteration, we need a way to signal the next iteration; let’s add that with a next()
method returned by start()
:
const scan = (reducer, init) => { const state = { accumulator: init, reducer: reducer, listener: () => {} }; const next = v => { state.accumulator = state.reducer(state.accumulator, v); state.listener(state.accumulator); }; const start = listener => { state.listener = listener; return { next }; }; return { start }; }
Now that we have a fully functioning scan()
& rAF()
, we can certainly animate, can’t we? Well yes, we can! What we have so far is the basic building blocks of animation!
However, we want these animations to be beautiful. To evoke the feeling of cutting butter with a hot knife, just like Neil Young’s voice after drinking a glass of wine; using a simple reducer here my friends, is certainly not that 😬:
Codepen Example: https://codepen.io/littlemilk/pen/KOZyjX
But not to fear, we’ve done the heavy lifting. Let’s create the last piece of the puzzle and wire this smooth cursor up.
The Lerp
So the last dependency of our smooth()
object is the lerp. This stands for Linear Interpolation, which does nothing more than given a start, end, and percent, calculates what point we’re at. Here’s a simple example for reference:
// progress is a value between [0-1] // lerp(start, end, progress) lerp(0, 2, 0.5) // result: 1 lerp(1, 3, 0.5) // result: 2 lerp(0, 4, 0.75) // result: 3
Traditionally we can use this to calculate the progress between two values where the start and end are fixed, and the progress changes from 0-1. However, for this tutorial we’re going to use this function similar to how it’s used in the context of noise smoothing.
In this application, start and end will be changing & progress will be constant. The Coding Train has an awesome breakdown of using p5.js‘s lerp()
to smooth out a series of input values that contain noise here.
Although the implementation of lerp in this context is the same as the traditional context, it helps to think about our inputs to the lerp()
function differently. In this context, we’ll use it a little more like this:
// roundness is a value between [0-1] // lerp(accumulator, target, roundness) let accumulator = 0; let target = 10; const roundness = 0.5; accumulator = lerp(accumulator, target, roundness) // 5 accumulator = lerp(accumulator, target, roundness) // 7.5 accumulator = lerp(accumulator, target, roundness) // 8.75 ...
In this implementation, roundness
describes how closely the accumulator
follows its input source. As the roundness
approaches 1, the accumulator
will have no smoothing and follow the input source exactly. As roundness
gets closer to 0, the accumulator will take more and more lerp iterations to reach the target
. The curve will become more and more exaggerated, hence naming this input roundness
.
Now that we have a high-level overview, let’s implement our lerp()
:
const lerp = (accum, target, roundness) => { return (1 - roundness) * accum + roundness * target; }
The implementation above is just a fancier version of this easier-to-understand version:
const lerp = (accum, target, roundness) => { const delta = target - accum; return accum += delta * roundness; }
The advantage of the fancier implementation is that it prevents a floating point error.
Now that we have our base lerp()
implementation, let's extend it to work with our point object we’ll be dealing with { x, y }
:
const lerp = (accum, target, roundness) => { return (1 - roundness) * accum + roundness * target; } const pointLerp = (accum, target, roundness) => { return { x: lerp(accum.x, target.x, roundness), y: lerp(accum.y, target.y, roundness) } };
Lastly, lets curry some of the inputs so it fits into the reducer()
signature for our scanner:
const lerp = (accum, target, roundness) => { return (1 - roundness) * accum + roundness * target; } const pointLerp = (roundness) => (accum, target) => { return { x: lerp(accum.x, target.x, roundness), y: lerp(accum.y, target.y, roundness) } };
Wooh! We’ve gotten through all of our dependencies. Now let’s wire up our smooth object.
Smooth
This is the object we referenced in the beginning. It will be used like so:
const smoothCursor = smooth({x: 0, y: 0}).start(({ x, y }) => { document.body.style.setProperty(‘--mouse-x’, x); document.body.style.setProperty(‘--mouse-y’, y); }); // Smoothly animates --mouse-x & --mouse-y css vars to { newX, newY } smoothCursor.update({ newX, newY });
To start, we will need a start()
method that lets us listen in on each animation frame. Let’s set that up:
const smooth = (init, { roundness = 0.1 } = {}) => { const state = { scan: null, loop: null, target: init }; const start = listener => { state.scan = scan(pointLerp(roundness), init).start(listener); state.loop = rAF().start(() => { state.scan.next(state.target); }); } return { start }; }
Now that we have something that is smoothly updating to target
on every frame, let's add a method that allows us to change the target
.
const smooth = (init, { roundness = 0.1 } = {}) => { const state = { scan: null, loop: null, target: init }; const update = v => { state.target = v; }; const start = listener => { state.scan = scan(pointLerp(roundness), init).start(listener); state.loop = rAF().start(() => { state.scan.next(state.target); }); return { update }; } return { start }; }
Woof, that was simple. The last thing we have to do is expose a method that stops the infinite animation loop. This should be a pretty easy add-in as well:
const smooth = (init, { roundness = 0.1 } = {}) => { const state = { scan: null, loop: null, target: init }; const update = v => { state.target = v; }; const stop => { state.loop.stop(); }; const start = listener => { state.scan = scan(pointLerp(roundness), init).start(listener); state.loop = rAF().start(() => { state.scan.next(state.target); }); return { update, stop }; } return { start }; }
Nice! Now we have a fully functioning input smoother, we can consume it in a cursor component like so:
const App = () => { const smoothedMouse = useMemo(() => { return smooth({x: 0, y: 0}).start(({ x, y }) => { document.body.style.setProperty(‘--mouse-x’, x); document.body.style.setProperty(‘--mouse-y’, y); }); }, []); const updateCursorPosition = e => { smoothedMouse.update({ x: e.clientX, y: e.clientY }); } useEffect(() => () => smoothedMouse.stop(), []); return ( <div className=”app” onMouseMove={updateCursorPosition}> <div className=”cursor”> </div> ); };
Codepen Example: https://codepen.io/littlemilk/pen/ZgvJym
Next Steps
If you’ve made it this far, awesome work following the tutorial all the way through. I hope you picked up some new tricks and learned some fundamentals along the way.
If you’re looking for further exploration, dive into the demo code and consider implementing a value()
object to keep track of things like velocity or direction between updates. This can add some extra flare to your custom cursor.
Make sure to keep exploring and playing with it. Feel free to make it your own and break whatever rules you want. Always remember, if it looks good, it is good. 🤘
References
- An Animated Intro to RxJS - David Khourshid: https://css-tricks.com/animated-intro-rxjs/
- Linear interpolation - Wikipedia: https://en.wikipedia.org/wiki/Linear_interpolation#Programming_language_support
- Popmotion API - https://popmotion.io/api/
- r - 🌿 A light JavaScript library. - Aristide Benoist: https://github.com/aristidebenoist