Animation with JavaScript
Make browser motion feel smooth with requestAnimationFrame, elapsed-time loops, cubic-bezier easing, CSS animation choices, and the Web Animations API.
- 01Run a frame loopUse
requestAnimationFrameand its timestamp to move something by the real time that passed, and stop the loop withcancelAnimationFrame. - 02Shape the speedExplain what a timing function does, what the four
cubic-bezier()numbers mean, and why motion can go past the end. - 03Pick the right toolChoose a CSS transition, CSS keyframes,
element.animate(), or a JavaScript frame loop for a real page task.
Smooth motion, one frame at a time
An animation is a picture that changes a little at a time, many times a second. On a web page, JavaScript can slide a box, fade in a panel, or move a game character.
The browser draws the page on the screen again and again. Each new drawing is called a repaint, and one drawing is called a frame. Good animation code does three things:
- It changes the picture just before the browser draws the next frame.
- It measures how much time really passed, instead of guessing.
- It moves less for people who ask their device for less motion.
This lesson starts with requestAnimationFrame, the tool for the first two. Then it covers timing functions, how to choose between CSS and JavaScript, and element.animate().
In the Timers lesson you met setInterval. It runs your code every few milliseconds, on its own clock. It does not know when the browser is about to draw. For motion, you want to ask two other questions:
- Is the browser ready to draw the next frame?
- How much time has passed since the last frame?
Think of drawing a flipbook. You draw one page at a time, and you start the next page only when you are ready for it. A kitchen timer is different: it rings when its time is up, whether you are ready or not.
- In real life: You are ready to draw the next page
- In JavaScript:
requestAnimationFrame(frame)runsframejust before the next repaint - In real life: You check the clock before each page
- In JavaScript: The timestamp tells you how much time has passed
- In real life: A kitchen timer rings on its own
- In JavaScript:
setIntervalruns on its own clock, not when the screen is drawn
JavaScript animation is code that changes what you see, a little at a time. Good animation code makes one change per frame, just before the browser repaints. It uses the real time that has passed, because frames do not all take the same time.
requestAnimationFrame
INTERACTIVErequestAnimationFrame(callback) asks the browser: "Run this function once, just before the next repaint." A callback is a function you hand over so that someone else can call it later. People often shorten requestAnimationFrame to rAF.
The browser passes one number to your callback: a timestamp. It is the time of this frame, in milliseconds (thousandths of a second). Every rAF callback in the same frame gets the same timestamp, so two animations on one page stay in step.
How often does a frame come? That depends on the screen. Many screens draw 60 frames a second. Others draw 90, 120, or a number that changes. In a hidden browser tab, frames may slow down or stop.
let rafId;let previous;let position = 0;const speed = 0.12; // pixels per millisecond function frame(timestamp) { const elapsed = previous === undefined ? 0 : timestamp - previous; previous = timestamp; position += speed * elapsed; dot.style.transform = `translateX(${position}px)`; rafId = requestAnimationFrame(frame);} rafId = requestAnimationFrame(frame);// Later: cancelAnimationFrame(rafId);Line by line:
- Lines 1 to 4 make four variables.
rafIdkeeps the request number.previouskeeps the last timestamp.positionis where the dot is.speedis 0.12 pixels per millisecond. - Line 6 starts the
framefunction. The browser calls it with the timestamp. - Line 7 works out
elapsed: the time since the last frame. On the very first frame there is no last frame, so it uses 0. - Line 8 saves this timestamp for the next frame.
- Line 9 adds speed times elapsed time to the position.
- Line 10 moves the dot on the screen with a CSS
transform. - Line 11 asks for the next frame. This is what makes it a loop.
- Line 14 asks for the very first frame. Line 15 is a note: later,
cancelAnimationFrame(rafId)stops the loop.
frame(timestamp) just before it paints the screen. Line 10 moves the dot, and line 11 asks for the next frame.The most important line is line 7, the elapsed time. You could move the dot a fixed amount every frame, like 2 pixels. But then the speed depends on the screen: a screen with 120 frames a second moves the dot twice as fast as one with 60. With elapsed time, the speed is set in pixels per millisecond. If a frame comes late, the dot moves farther in that frame, so it stays on time.
const startedAt = 1000;
const now = 1300;
const duration = 1000;
const progress = progressFraction(startedAt, now, duration);
bar.style.transform = `scaleX(${progress})`;
console.log(`${Math.round(progress * 100)}%`);This example uses fixed times, so it prints the same thing every time. progressFraction is a small helper this lesson provides. It works out (now − start) ÷ duration and keeps the answer between 0 and 1.
- Lines 1 to 3: the task started at 1,000 ms, it is now 1,300 ms, and the task takes 1,000 ms.
- Line 4: 300 ms out of 1,000 ms have passed, so
progressis 0.3. - Line 5 stretches the bar to 30% of its full width with
scaleX(0.3). - Line 6 prints
30%.
On a real page, a rAF loop would give you a new "now" on every frame, and the bar would grow as time passes.
let rafId;let previous;let position = 0;const speed = 0.12; // pixels per millisecond function frame(timestamp) { const elapsed = previous === undefined ? 0 : timestamp - previous; previous = timestamp; position += speed * elapsed; dot.style.transform = `translateX(${position}px)`; rafId = requestAnimationFrame(frame);} rafId = requestAnimationFrame(frame);// Later: cancelAnimationFrame(rafId);- Mode
- by elapsed time
- Position
- 0.0 px
- Slow frame
- not yet
Press Start when you're ready. Nothing moves until you do. Reset uses cancelAnimationFrame with the saved request id.
The lab does not start moving when the page loads. Real pages should do the same, unless the motion is needed and safe. Let the reader press a button to start.
requestAnimationFrame returns a number called the request id. Always keep it. To stop the loop, pass it to cancelAnimationFrame(id). Do this when the reader pauses or resets, and when that part of the page is removed.
function pauseWhenHidden() {
if (document.hidden && rafId !== undefined) {
cancelAnimationFrame(rafId);
rafId = undefined;
previous = undefined;
} else if (!document.hidden && rafId === undefined) {
rafId = requestAnimationFrame(frame);
}
}
document.addEventListener("visibilitychange", pauseWhenHidden);Line by line:
- Line 11 runs
pauseWhenHiddeneach time the tab is hidden or shown again. That is thevisibilitychangeevent. - Lines 2 and 3: if the tab is hidden and a frame is waiting, cancel that frame.
- Lines 4 and 5 clear the request id and the last timestamp.
- Lines 6 and 7: if the tab is shown again and no frame is waiting, ask for a new one.
Why clear previous on line 5? If you don't, the first frame after the tab comes back counts the whole hidden time as elapsed time, and the dot jumps far ahead. Browsers often slow down or stop rAF in hidden tabs anyway, but your own variables still need to be right. When that part of the page is removed, remove the visibilitychange listener too.
The replay below compares the two ways to move the dot. It uses three made-up timestamps: 0, 16.7, and 50 ms. The last frame is late: it comes 33.3 ms after the one before, not about 16.7 ms.
Switch the setting and replay. With the same timestamps, moving by elapsed time keeps up after the late frame, and moving a fixed amount falls behind.
script
let position = 0;let previous = timestamps[0];for (const timestamp of timestamps.slice(1)) { const elapsed = timestamp - previous; position += 0.12 * elapsed; console.log(position.toFixed(2)); previous = timestamp;}Press Step to go line by line. Then switch the setting and replay. Moving by elapsed time prints 2.00 and 6.00. Moving a fixed 2 pixels per frame prints 2.00 and 4.00.
Timing functions and Bézier curves
PLAYGROUNDMoving at one speed the whole time often looks stiff. A drawer that starts slowly and stops slowly looks natural. A shake that warns about an error should start fast.
A timing function, also called an easing, controls this. It takes how much of the time has passed, from 0 to 1. It gives back how far along the motion is, usually also from 0 to 1.
Think about pulling open a drawer. You can start gently, slow down near the end, or pull at one steady speed. Each way feels different, even though the drawer goes the same distance in the same time.
- In real life: The drawer starts slowly, then speeds up
- In JavaScript:
ease-instarts slowly - In real life: The drawer slows down near the end
- In JavaScript:
ease-outends slowly - In real life: The drawer moves at one steady speed
- In JavaScript:
linearkeeps the same speed - In real life: You choose exactly how it slides
- In JavaScript:
cubic-bezier()sets your own curve
CSS has names for common timing functions: linear, ease, ease-in, ease-out, and ease-in-out. Each one is a cubic Bézier curve: a smooth curve set by four numbers, written cubic-bezier(x1, y1, x2, y2). The x numbers are about time. The y numbers are about progress.
ease curve, half the time gives 0.802.The lab below shows this code. cubicBezier is a helper this lesson provides. Line 1 makes an easing function from the four numbers of ease. Lines 2 to 4 print 0, 0.802, and 1. So with ease, half the time gives about 80% of the motion: it moves fast at first and slows near the end.
const easing = cubicBezier(0.25, 0.1, 0.25, 1);console.log(easing(0));console.log(easing(0.5).toFixed(3));console.log(easing(1));The x sliders only go from 0 to 1, because time must move forward. The y sliders can go below 0 or above 1. Then the motion goes past the end and comes back, like a bounce.
cubic-bezier(...) text. You can use it in a CSS transition, in CSS keyframes, or in the timing of element.animate().Two rules for the four numbers:
- The x numbers must stay from 0 to 1. They are about time, and time can't go backward or past the end.
- The y numbers can go below 0 or above 1. Then the motion goes past the end and comes back, like a bounce. This is called overshoot.
Newer browsers also have a CSS linear() timing function. It builds a curve from several straight pieces. Older browsers don't know it. If your page needs it, check first with CSS.supports() in the browser, after the page has loaded, and keep a simpler easing as a backup.
CSS or JavaScript animation?
SORTCSS can animate too, and it is often simpler and smoother. Use it when you know the motion ahead of time. CSS has two tools for this:
- A transition animates a change from one style to another, like a button that grows a little when the mouse is over it.
- A
@keyframesanimation plays a list of steps you wrote ahead of time, like a spinner that turns again and again.
With CSS, the browser works out every in-between step for you. For transform and opacity, it can often do this on the compositor: a separate part of the browser that moves and fades parts of the page that are already drawn. It does not need your JavaScript for that.
Your JavaScript runs on the main thread: the one line of work where the browser also handles clicks and works out the page layout. Use JavaScript for motion that needs decisions while it runs:
- live measurements, like how fast the page is scrolling
- physics, like a ball that bounces
- drawing on a canvas
- keyframes that your code builds
- buttons to pause, reverse, or finish the motion
A cartoon on TV plays scenes that were drawn ahead of time. A live artist draws each picture as they go, because the next picture depends on what just happened.
- In real life: A cartoon plays scenes drawn ahead of time
- In JavaScript: CSS keyframes: the browser plays them
- In real life: A live artist draws the next picture now
- In JavaScript: JavaScript can decide each frame
- In real life: The scenes are known before the show
- In JavaScript: CSS suits motion you know ahead of time
- In real life: Each new picture needs a new choice
- In JavaScript: JavaScript gives control, but uses the main thread
The table compares the four tools. Read each row as: "use this tool for these jobs; this is what it does well; this is what to watch for."
| Tool | Use it for | What it does well | What to watch for |
|---|---|---|---|
| CSS transition | A change from one style to another, like a hover effect or a panel that opens and closes. | The browser can often animate transform and opacity very smoothly. | Your code can't make a new choice on each frame. |
CSS @keyframes | Motion you can reuse and know ahead of time, like a loading spinner. | You write the steps once, and the browser plays them. | It can't run logic or live physics. |
element.animate() | Keyframes your code builds, with buttons to control the motion. | It returns an Animation object with play(), pause(), reverse(), and finish(). | You still need to clean up, and to think about fill. |
requestAnimationFrame | Canvas loops, games, physics, and anything that measures on every frame. | Your callback gets the frame's timestamp, in step with the screen. | You run the loop yourself, and it uses main-thread time. |
CSS can also tell your JavaScript when an animation ends:
transitionendfires when the transition of one property ends.animationendfires when a CSS@keyframesanimation ends.- If a transition is stopped early,
transitionenddoes not fire.
const button = document.querySelector("button");
const spinner = document.querySelector(".spinner");
button.addEventListener("transitionend", (event) => {
console.log(event.propertyName);
});
spinner.addEventListener("animationend", () => {
spinner.classList.remove("is-spinning");
});Line by line:
- Lines 1 and 2 find the button and the spinner on the page.
- Lines 4 to 6: when one of the button's transitions ends, print the name of that property, like
transform. - Lines 7 to 9: when the spinner's animation ends, remove its
is-spinningclass.
When that part of the page is removed, remove these listeners too.
<button type="button" id="stop-spinner">Stop loading</button>
<span class="spinner is-spinning" role="status" aria-label="Loading"></span>
<script>
document.querySelector("#stop-spinner").addEventListener("click", () => {
document.querySelector(".spinner").classList.remove("is-spinning");
});
</script>
<style>
.spinner { display: inline-block; inline-size: 1rem; block-size: 1rem; border: 2px solid currentColor; border-inline-end-color: transparent; border-radius: 50%; }
.spinner.is-spinning { animation: spin 700ms linear infinite; }
@keyframes spin { to { transform: rotate(1turn); } }
@media (prefers-reduced-motion: reduce) { .spinner.is-spinning { animation-duration: 1ms; animation-iteration-count: 1; } }
</style>Here CSS does the turning. prefers-reduced-motion: reduce on line 14 matches when the person has turned on a "reduce motion" setting on their device. Line by line:
- Lines 1 and 2 make a Stop button and a spinner with the
is-spinningclass. - Lines 5 to 7: a click on the button removes the
is-spinningclass. - Line 12: while the spinner has that class, it turns once every 700 ms, forever.
- Line 13 is the
@keyframeslist: turn all the way around. - Line 14: for people who ask for less motion, the spinner turns once, very fast, and stops.
To stop the spinner, you remove a class. There is no JavaScript loop to cancel.
- A button grows a little when the mouse is over it
- A drawer opens and closes when a class changes
- A loading spinner used on many pages
- A pulse that repeats three times to get attention
- A message slides in, with a duration from the user's settings
- Pause, reverse, and finish a tutorial card's animation
- A game character moves by speed and bumps into walls
- Motion that follows how fast the page is scrolled
Put each task under the tool you would try first.
element.animate()
PLAYGROUNDThe Web Animations API sits between CSS and your own rAF loop. You call element.animate(keyframes, timing):
keyframesis a list of styles to move through, like fromtranslateX(0)totranslateX(160px).timingis an object with options, likedurationin milliseconds andeasing.
The browser plays the animation for you, so you don't write a frame loop. It also returns an Animation object right away. You use that object to control the animation from your code:
- Methods:
play(),pause(),reverse(), andfinish(). - Properties:
playbackRateis the speed.playStatesays what it is doing, like"running"or"paused".currentTimeis how far in it is, in milliseconds. - A
finishedpromise. A promise is an object that tells you later when something is done. This one is done when the animation ends.
card.animate() gets the keyframes and the timing, starts the motion, and returns an Animation object. You call its methods, like play() and pause(), to control it.A TV remote doesn't draw the picture on the TV. The TV does that. The remote only lets you play, pause, skip, and see what is happening.
- In real life: Press play or pause
- In JavaScript: Call
play()orpause() - In real life: Skip forward or rewind
- In JavaScript: Set
currentTimeor callreverse() - In real life: Look at whether the show is playing
- In JavaScript: Read
playState
const animation = card.animate( [ { transform: "translateX(0)", opacity: 0.6 }, { transform: "translateX(160px)", opacity: 1 }, ], { duration: 900, easing: "ease-out", iterations: 1, direction: "normal", fill: "forwards", },); animation.finished.then(() => { animation.commitStyles(); animation.cancel();});- playbackRate
- 1x
- reduced motion
- no preference
Press Play to make an Animation object. Each button calls one of its methods. The status shows playState, currentTime, and when the finished promise is done.
fill: "forwards" keeps the end style showing, but finished animations can pile up. commitStyles() saves the end style on the card, and then cancel() removes the animation.const animation = card.animate( [ { transform: "translateX(0)", opacity: 0.6 }, { transform: "translateX(160px)", opacity: 1 }, ], { duration: 900, easing: "ease-out", iterations: 1, direction: "normal", fill: "forwards", },); animation.finished.then(() => { animation.commitStyles(); animation.cancel();});Line by line:
- Line 1 calls
card.animate()and keeps theAnimationobject inanimation. - Lines 3 and 4 are the keyframes: start at
translateX(0)with opacity 0.6, and end 160 pixels to the right with opacity 1. - Lines 7 to 10 are the timing: 900 ms,
ease-out, played once, in the normal direction. - Line 11,
fill: "forwards", keeps the end style showing after the animation ends. Without it, the card would jump back to where it started. - Line 15 waits for the
finishedpromise. - Line 16,
commitStyles(), writes the end style into the card's ownstyleattribute. - Line 17,
cancel(), removes the animation. The card stays where it ended, because line 16 saved that style.
Where you'll use this
In real work, pick the simplest tool that does the job:
- A menu that fades in can be a CSS transition, as in the Styles and classes lesson.
- A tutorial card that must pause when a dialog opens can use
element.animate(). - A canvas game, or a chart you drag through, can use rAF. Keep slow work, like measuring the page layout, out of the frame function.
- Animate
transformandopacitywhen you can. - In rAF, measure the elapsed time. Never assume a frame takes 16.7 ms.
- Cancel loops and animations when that part of the page is removed.
- Read the reduced-motion setting in the browser, after the page loads, and offer a calm version.
- When later code must wait for an animation to end, use
animation.finished.
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const duration = fadeDuration(reduceMotion);
const fade = panel.animate(
[{ opacity: 0 }, { opacity: 1 }],
{ duration, fill: "forwards" },
);
console.log(reduceMotion ? "instant fade" : "400 ms fade");Line by line:
- Line 1 asks the browser whether the person wants less motion.
matchMedia()checks a CSS media query, and.matchesistrueorfalse. - Line 2 uses
fadeDuration, a helper this lesson provides: 1 ms for less motion, 400 ms otherwise. - Lines 3 to 6 fade the panel from opacity 0 to 1 and keep the end style.
- Line 7 prints which fade was used.
The panel still appears, so the person still sees that something changed. It just doesn't take a long trip across the screen.
Only the browser knows this setting. The server builds the page before it reaches the browser, so it can't read it. Run code like this in the browser, after the page loads, for example in a click handler.
Common mistakes
MISTAKES- "rAF means 60 frames a second." It means "just before the next repaint." A screen might draw 60, 90, or 120 frames a second, a number that changes, or fewer in a hidden tab.
- "Moving 2 pixels per frame is fine." Then the speed changes from screen to screen, and the motion falls behind after a slow frame.
- "All CSS animation is free." Animating
transformandopacityis often cheap. Animating sizes or positions, likewidthortop, makes the browser work out the layout again on every frame, and that can be slow. - "JavaScript animation runs off the main thread." Normal rAF callbacks, and style changes made from JavaScript, run on the main thread with the rest of your code.
- "
fill: "forwards"cleans up." It keeps the end style showing. It does not remove the finished animation.commitStyles()and thencancel()do that. - "Reduced motion means no animation design." It means less movement: instant changes, short fades, or progress in small steps.
Practice exercises
5 EXERCISESRead the code without running it. Which two values does it print?
const timestamps = [0, 16.7, 50];
let position = 0;
let previous = timestamps[0];
for (const timestamp of timestamps.slice(1)) {
const elapsed = timestamp - previous;
position += 0.12 * elapsed;
console.log(position.toFixed(2));
previous = timestamp;
}2.00
6.00The first frame moves about 2 pixels. The late frame moves about 4 more, so the second line is 6.00.
This version moves a fixed amount per frame. Which two values does it print?
const timestamps = [0, 16.7, 50];
let position = 0;
for (const timestamp of timestamps.slice(1)) {
position += 2;
console.log(position.toFixed(2));
}2.00
4.00The loop runs twice and adds 2 pixels each time. The late frame doesn't move any farther, and that is the bug.
The loop keeps running after Reset. Which line is missing from reset()?
let rafId;
function start() {
rafId = requestAnimationFrame(frame);
}
function reset() {
position = 0;
}function reset() {
cancelAnimationFrame(rafId);
rafId = undefined;
position = 0;
}reset() must cancel the waiting frame. If it doesn't, the old loop keeps running and keeps moving the dot after the reset.
Write down which tool you would use for each: an underline that appears on hover, a loading shimmer that repeats, a welcome card the user can pause, and moving particles drawn on a canvas.
A hover underline is a transition. A loading shimmer that repeats is @keyframes. A welcome card you can pause is element.animate(). Canvas particles are a rAF loop.
Write code that picks calm timing when the person asks for less motion.
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
// If media.matches, show the final state without long travel.const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const timing = reduce ? { duration: 1 } : { duration: 600, easing: "ease-out" };
const animation = panel.animate(keyframes, timing);The key part is the choice on line 2. The page works the same either way, but people who want less motion get a 1 ms change instead of a long one.
Check your understanding
7 QUESTIONSQuestion 1 of 7Why is
requestAnimationFrameusually better thansetIntervalfor motion on the screen?Choose an answer to see the explanation.
Question 2 of 7This code moves by elapsed time. What does it print after the late frame?
Read the code, then predictconst timestamps = [0, 16.7, 50]; let position = 0; let previous = timestamps[0]; for (const timestamp of timestamps.slice(1)) { const elapsed = timestamp - previous; position += 0.12 * elapsed; console.log(position.toFixed(2)); previous = timestamp; }Choose an answer to see the explanation.
Question 3 of 7Which
cubic-bezier()can CSS use?Choose an answer to see the explanation.
Question 4 of 7Which two properties are usually the smoothest to animate?
Choose an answer to see the explanation.
Question 5 of 7What does
element.animate(keyframes, timing)return?Choose an answer to see the explanation.
Question 6 of 7What should you watch for with
fill: "forwards"?Choose an answer to see the explanation.
Question 7 of 7How should a page respect the reduced-motion setting?
Choose an answer to see the explanation.
Key takeaways
requestAnimationFrameruns your code just before the next repaint. It passes a timestamp that every rAF callback in that frame shares.- Moving by elapsed time keeps the speed right after slow frames and on fast screens. Moving a fixed amount per frame does not.
- A timing function shapes the speed. In
cubic-bezier(), the x numbers stay from 0 to 1; the y numbers can go past that. - Use CSS for motion you know ahead of time,
element.animate()for motion your code builds or controls, and rAF when every frame needs new math. - Respect reduced motion, don't start motion on your own, animate
transformandopacity, and clean up loops and finished animations.
JavaScript animation changes what you see one frame at a time: ask the browser for each frame, measure the real time, and pick the simplest tool that gives you the control you need.
Up next: Drawing with canvas.