I should say at the outset that there’s no real point to this blog post. I had a thing to do, I did the thing, and was moderately pleased with the result. I enjoy reading of such tales from other developers, so I thought I’d share my story.
Recommended music pairing for this post: the new Awolnation album.
Moving something smoothly from one place to another is something I need to do in most sites I work on. Most of the time it’s nothing more than smoothly scrolling the page to some position or sliding out a navigation menu.
The size and complexity of pre-packaged solutions seemed, to me, to be out of proportion with the simplicity of the task. So I made my own.
Way back when this story first took place, I assumed that easing functions were quite math-heavy, and was happy to leave them in the hands of cleverer-than-me people.
At first I used jQuery and its
animate() function. I’m ashamed to say that in one site I loaded 60 KB of jQuery for no other purpose that scrolling the page smoothly.
Then npm became a thing and I used whatever showed up in the npm search results for ‘easing’.
Then I learned that web performance was a thing users cared about so I ran some time-trials and released a sheepish ‘eek’.
Here’s the load times for a site I’m working on with a typical easing package and the home made one I will very slowly reveal in this post.
I don’t know about you, but I consider adding ~100ms to my load time a pretty big deal. Remember Amazon calculated that adding 100ms cost 1% in sales.
(Whether or not 100ms matters to you should be a factor of your website’s revenue. If you’re getting 20 hits a day on a site about meditation techniques, then 100ms is probably nothing to concern yourself with — if anything it’s good practice.)
Oh and 20 KB — on a 150 KB site — just to scroll the page is outrageous.
Let me be clear though, it’s not so much the fault of the packages, they’re built to do a lot more. The issue is that the code I need is only a few hundred bytes, but it comes inextricably bundled with tens of thousands of bytes of other code made for other people. If I used a package like this, it would be my own damn fault that my load time increased.
Starting out: a linear transition
I know my easing function is going to need at least three parameters: a start value, an end value, and a duration. I figure this is a good start:
This is going to need some sort of loop that performs an operation over and over from the start value to the end value. I’ll use
requestAnimationFrame for this so that each loop of the code runs once per frame.
First, a single-paragraph animation primer to bring everyone up to speed. The screen that your face is currently pointed at is probably 60Hz, which means it refreshes 60 times every second. That’s a feature of the actual hardware. Operating systems and browsers don’t constantly calculate what colour each pixel on the screen should be, they ‘only’ do it when they need to, which is 60 times each second. Or once every 16 milliseconds. This is called a frame, or animation frame, and
requestAnimationFrame says explicitly to the browser, do this work for the next time you’re about to update the pixels on the screen. By calling it again and again, you’ve created a loop that only runs once for each time the browser updates the screen, thus being the minimum work for the maximum frame rate.
In the above snippet I have a function (
step) to do the actual work, which I repeatedly call (by passing it to
requestAnimationFrame) as long as some condition is true. This is more or less like a
while loop (with the same risk of an infinite loop if you get something wrong).
currentValue on each loop, so I can be pretty sure that it will eventually be more than
endValue and that will break the loop.
I want this function to be usable in all sorts of situations. So rather than have any update logic embedded in the function, I’ll just pass a callback that will get called on each loop/step/tick/whatever. So the “what” of the easing is left up to the code that calls the function.
I have a personal rule (just one) that if a function has more than three parameters, I use ‘named parameters’. Then I can keep track of what I’m passing in, and it makes handling optional parameters easier (since the user doesn’t need to pass in placeholder
null values to satisfy parameter order).
Here’s five bullet points about the code snippet below the bullet points:
- The function now takes a single parameter, an object. That object can have the properties
onStepcallback which gets called for each step with the current value.
- I’m using object destructuring syntax to unpack these properties into variables.
- I’m using the default parameter syntax (
- When the ‘loop’ has finished, I call
onStepone last time with the
endValue. This ensures the thing finishes where you asked it to, even if there’s rounding issues.
I now have a functioning function that I can call like so:
Sure enough, this spits out values between 1,000 and 2,000
Note that I didn’t pass in
durationMs, so it will default to 200 milliseconds. A handsome, no-nonsense duration.
If I was feeling frisky I could print these values as a pretty chart in the console:
Unfortunately the above easing function is now in code jail for breaking Newton’s first law.
Easing into the hard stuff
To look more natural, I want my transition to simulate a little inertia and momentum; I want it to have the same physics as sliding a beer along a bar (normal physics, not Ted Danson physics).
Specifically, I want it to move slowly at the start, faster in the middle, then gradually come a standstill at the end.
Since the time for each step is fixed (~16ms), and speed is an illusion perpetrated by our visual system, we can focus solely on the distance moved in each step.
An example: let’s say I want to slide something by 100 pixels over 100 steps. In the linear example, each step would be a distance of 1 pixel.
To get an easing effect, I need to adjust each of these steps so they’re smaller numbers at either end.
Math.sin() is the function of which I speak. If I pass in zero, I get zero. When I’m half way through the animation I pass in half of PI and I’ll get one. When I’m 100% done with the animation I pass in 100% of PI and I’ll get zero again.
So I can modify my linear function and simply multiply each step by
Math.sin(progress * Math.PI).
But there’s a problem. By the time my little whatever has moved each of those steps, it will only have travelled about 63.657 pixels. That’s not far enough. Not far enough at all.
I know what you’re thinking: David, just multiply each step by half of PI!
Although I appreciate the advice that I imagined you giving me, and that does indeed make all the steps add up to 100, I’d actually like a bit more oomph in the acceleration and deceleration.
So I will respectfully dismiss your advice and instead square then double each of these values.
Now, I have been talking of these bars as though they represent pixels, in a scenario where something needed to move 100 pixels, over 100 steps. That was a (probably unnecessary) simplification of the reality that these are actually multipliers of each step. They don’t need to actually add to 100, or 100% of the distance, and there usually won’t be exactly 100 of them.
The important part is that after the multiplier is applied to each step — so that the middle steps are bigger and the start and end ones are smaller — the sum of all the steps equals the total distance that the thing needs to travel.
Previously, in my loop, I only needed to keep track of the current value (how far the thing had moved — the number passed to the callback). But now I need to also keep track of the multiplier.
So I need to increment both of these at each step.
The way I contain the loop is to check if
currentSinValue < Math.PI. You may wonder, why don’t I just check if
currentValue < endValue?
Well, my curious little friend, that’s because
startValue can be more than
endValue (e.g. when scrolling to the top of the page you might animate from 1,000 to 0). This is what was wrong with the linear function — it would be an infinite loop when the end value was less than the start.
currentSinValue is always going to be incremented by a positive fraction of PI, I know that once it’s over 3.14ish, the animation is finished.
If I check the console I can see that the progress is nice and curvy.
That’s all there is to it!
Remember when I said “That’s all there is to it”? Yeah, that was a lie.
ease function is first called, it kicks things off by calling
step(). This will increment the value and call the
onStep callback. So far so good. But it then calls
step again — via
requestAnimationFrame — which executes immediately, to be ready for the next frame. So two steps happen in the same frame (which means the first one is never seen).
It’s not noticeable to the human eye, but who’s to say that only humans look at your site? Hmmm?
For this reason, I’ll wrap that initial
While I’m at it, I’m going to do a rough polyfill of
requestAnimationFrame by calling
Lastly, what if a user wants to do something special once the animation is complete? Perhaps have a little party.
I considered wrapping the interior of
ease() in a promise and calling
resolve() when the animation was finished. This way I could chain
.then or even
await ease(...), but this seemed a bit convoluted.
Instead, the function accepts an
onComplete callback that will be called once the animation is done. It has a default value of an empty function so I don’t need to check for its existence before calling it.
With those changes, the final function looks like this:
Regardless, it’s tiny.
Using the thing
Now for the simple part. Let’s say you have a ‘scroll to top’ button at the bottom of a page. When clicked, you want to scroll — starting at the current scroll position, ending at the top (scroll position = 0), adjusting
window.scroll as you go.
But you believe in an inclusive web, so you also want to shift keyboard focus to an element at the top of the page for your keyboard/screen-reader friends (any time you’re shifting stuff around on the screen is a good time for an accessibility check-in).
But if you call
.focus() on an element that isn’t in view, some browsers (e.g. current Chrome) will snap the window to a position where the element is visible. So you’ll need to wait until the animation is finished.
You can have a play with an example just like this over on CodePen.
Some talk about performance
The below only really matters if you’re having trouble hitting 60 FPS.
If you’re having trouble hitting 60 FPS, use your DevTools, check out what’s eating up your 16 millisecond budget and target that. Oh and read all of Paul Lewis’ Google Developer articles on rendering performance, they’re super great.
Lastly, a bit of (probably unpopular) advice. If you can’t get your animations smooth for most users, ditch them. No point having a buttery smooth slide-down top menu on your hot new Mac if 70% of your users are on phones where it looks crappy.
You can, sort of, have the best of both worlds though… At the risk of putting too much code in one blog post (can there be such a thing?) here’s a neat way to test if your animations are running smoothly for a user, and bypassing the animation if they’re not.
So when Apple plays Logan’s Run with your user’s old iPhone and it can no longer handle those gorgeous animations, your site won’t go all janky as the CPU fails to keep up.
Why not use the Web Animations API?
- It can’t animate
- Support is terrible
I feel like this section should have at least three lines of text.
This is now on npm and there’s totally nothing to worry about
You may have read a post recently describing how npm packages could be used as a delivery mechanism for malicious code.
Because of this, when I decided to publish this to npm, I did a little re-write so it’s all ES5 and doesn’t require any build/transpilation step and doesn’t publish anything to npm that isn’t visible in Github.
That’s all, folks
Thanks for reading, have a spectacular day!