JavaScript animations are usually slow and jumpy, but we can animate just about anything and make it buttery smooth with requestAnimationFrame
.
When you animate a property via a continuous JavaScript event like scroll
, mousemove
, touchmove
or resize
, it often becomes quite jumpy. This is because the time to calculate multiple layout changes is slower than one animation frame (~16ms at 60fps).
Why not just use CSS animations?
You can and you should, but CSS animations do not work for every type of animation.
CSS animations are hardware accelerated and are an excelent way to smoothly animate an element's position, scale, rotation or opacity. However, CSS animations don't have the ability to alter other valuable properties like width and height, dynamically change a layout or update SVG attributes.
Using requestAnimationFrame
A good way to throttle events is by calling requestAnimationFrame
in an event listener callback.
const scroll = (event) => {
requestAnimationFrame(() => {
// do something with event data
})
}
window.addEventListener('scroll', scroll)
The requestAnimationFrame
method requests that the browser executes the callback function right before the next repaint. This does reduce the number of repaint calculations per frame, but this does not prevent executing the callback multiple times, or prevent the browser from recalculating the page layout multiple times.
If either the callback code or the layout calculation takes longer than 16ms, or the callback is called more often than every frame, you may notice a slow and jumpy animation.
We can prevent those jumps by cancelling all previous requestAnimationFrame
requests and ensuring that the callback is only invoked once and the layout is only recalculated once.
Building a frame service
Let's create a class called FrameService
and have it store the requestId
of each animation frame. If the debounce
method is called again before the callback is invoked, we cancel the previous request.
class FrameService {
frame = null
debounce = (callback, ...args) => {
if (this.frame) this.cancel()
this.frame = requestAnimationFrame(() => {
this.frame = null
callback(...args)
})
}
cancel = () => {
cancelAnimationFrame(this.frame)
}
}
Canceling the previous request ensures only the most recent callback function is invoked prior to our frame being painted. On fast devices, you may not notice much of a difference, but on slower devices like older phones, or slower environments like React Native, you'll notice a near complete reduction in jumps.
To use the FrameService
, create a new instance and call the debounce
method. As long as you are using the same instance, it will only invoke the callback function at most once per frame.
const frameService = new FrameService()
const scroll = (event) => {
frameService.debounce(() => {
// do something with event data
})
}
window.addEventListener('scroll', scroll)
If you need to cancel the callback function for any reason before the frame is rendered, simply call cancel
on the FrameService
instance.
frameService.cancel()
Wrapping it up
JavaScript animations don't have to be slow and jumpy and you don't have to be tied down by the limitations of CSS animations. Next time you need to use a JavaScript animation, smooth it out by using this FrameService
to ensure only one callback is invoked per frame.