Native Browser APIs for Dynamic User Interfaces
Browser Animation APIs
Beyond Static Content

A box that jumps from one place to another is hard to follow. A box that slides there shows your eye where it went. That small difference is what this post is about: making things fade, slide, and appear as you scroll, using only what the browser already ships. Motion earns its place when it guides attention, confirms that a click did something, or shows how two parts of a page are related. It does not need to be decoration.
In this article, we are going to fade or slide an element with one call, pause or reverse it from a button, start it when it scrolls into view, and keep it smooth for people who asked their system for less motion.
Two things to know first: a CSS property such as opacity (0 is invisible, 1 is solid) or transform (moves, scales or rotates an element) is what an animation changes, and document.querySelector('.box') gives you the element with class box.
Try it first#
The playground below runs two real browser features. Press Pulse once on the card and watch it lift a few pixels and swell slightly before settling. Then scroll the small viewport and watch the readout change. The playground also has panels for layout transitions and a popover; ignore those, they belong to other topics.
The Web Animations API plays a keyframe animation with one call. The playground's pulse is card.animate(keyframes, { duration: 500, easing: 'ease-in-out' }) with three keyframes: at rest, lifted 8 pixels and slightly enlarged, and back at rest. The card eases up and settles over half a second. If your system is set to reduce motion, the pulse is skipped and the status line says so.
The scroll viewport shows an IntersectionObserver, the other tool in this post. As you scroll, it reports how much of a target box is visible, for example "50% visible in demo viewport". It reports only when the amount crosses 0, 25, 50, 75 or 100 percent, instead of on every scroll step.
Each experiment first checks whether your browser supports the API and says so when it does not.
The pulse is one function call, no timers, no loop, and no frame-by-frame math from you. The rest of this post builds up to that call, then to a reveal-on-scroll effect, then to keeping it all smooth.
The same animation, two ways#
Here is a half-second fade in CSS:
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.box { animation: fade-in 500ms ease-in-out; }
And the same fade in JavaScript, using the Web Animations API (WAAPI), which is the browser's own animation engine exposed to scripts:
const box = document.querySelector('.box');
box.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 500, easing: 'ease-in-out' });
Both are run by the same engine. The CSS version is fixed in the stylesheet and starts when the element appears. The JavaScript version starts exactly when you call it, can use values you work out at runtime, and hands back an Animation object that you can pause, reverse, or wait on. So: CSS for fixed hover effects and state changes, animate() when code decides when and how. A third option, moving things yourself with requestAnimationFrame and your own math, is worth it only when each frame needs custom logic, as in the physics demo near the end.
Building up element.animate()#
Every snippet below assumes const box = document.querySelector('.box');. We add one idea at a time.
1. Keyframes and a duration. The first argument is a list of keyframes, the second says how long (in milliseconds):
box.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 500 });
2. Easing. By default the motion is constant speed. easing changes the feel:
box.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 500, easing: 'ease-in-out' });
The names are shortcuts for curves. ease-in-out is exactly cubic-bezier(0.42, 0, 0.58, 1), so you only need to write a cubic-bezier(...) when none of the names feels right.
3. What happens afterwards. Now fade out instead:
box.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 500 });
The box fades out and then pops back into view. After an animation finishes, the element returns to whatever its normal CSS says, which here is fully visible. To hold the last keyframe, ask for it:
box.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 500, fill: 'forwards' });
4. Repeating. iterations says how many times, and Infinity means forever. direction: 'alternate' plays forward, then backward, instead of snapping back to the start each time:
box.animate(
[{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }],
{ duration: 1000, iterations: Infinity, direction: 'alternate' }
);
5. More than two keyframes. Keyframes are spread evenly across the duration. A keyframe's offset (0 to 1) places it explicitly, so this one reaches the halfway opacity 30% of the way in:
const keyframes = [
{ transform: 'translateX(0)', opacity: 0 },
{ transform: 'translateX(50px)', opacity: 0.5, offset: 0.3 },
{ transform: 'translateX(100px)', opacity: 1 }
];
const animation = box.animate(keyframes, { duration: 1000, easing: 'ease-in-out' });
6. Control. animate() returns an Animation object. Keep it in a variable and you can steer it:
animation.pause();
animation.play();
animation.playbackRate = 2; // double speed; 0.5 is half
animation.reverse(); // play backwards from wherever it is now
This is the control a CSS animation does not give you without extra work, and it is the reason to reach for the API. Try all of it in the demo: pause it mid-flight, change the speed, then reverse.
---html
<h1>Web Animations API Demo</h1>
<div class="demo-container">
<div class="animation-stage">
<div class="animated-box"></div>
</div>
<div class="controls">
<button class="control-button" data-action="play">Play</button>
<button class="control-button" data-action="pause">Pause</button>
<button class="control-button" data-action="reverse">Reverse</button>
<button class="control-button" data-action="slow">Slow</button>
<button class="control-button" data-action="normal">Normal</button>
<button class="control-button" data-action="fast">Fast</button>
</div>
<p class="description">
This demo showcases the Web Animations API, which provides JavaScript with direct access to the browser's animation engine. Try the controls to see how you can manipulate the animation programmatically.
</p>
<div class="code-snippet">
<code>
// Basic animation using WAAPI<br>
element.animate([<br>
{ transform: 'translateX(0)' },<br>
{ transform: 'translateX(200px)' }<br>
], {<br>
duration: 2000,<br>
iterations: Infinity<br>
});<br>
</code>
</div>
</div>
---css
:root {
--primary-color: #5e60ce;
--secondary-color: #64dfdf;
--dark-color: #222;
--light-color: #f8f9fa;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--dark-color);
color: var(--light-color);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px;
}
h1 {
margin-bottom: 2rem;
text-align: center;
font-weight: 300;
color: var(--secondary-color);
}
.demo-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
border-radius: 8px;
background-color: rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.animation-stage {
position: relative;
width: 300px;
height: 100px;
margin-bottom: 2rem;
border-bottom: 2px dashed rgba(255, 255, 255, 0.2);
}
.animated-box {
width: 50px;
height: 50px;
background-color: var(--primary-color);
border-radius: 4px;
position: absolute;
top: 25px;
left: 0;
cursor: pointer;
}
.controls {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin-bottom: 1.5rem;
}
.control-button {
padding: 8px 12px;
border: none;
border-radius: 4px;
background-color: var(--primary-color);
color: white;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s, transform 0.1s;
}
.control-button:hover {
background-color: var(--secondary-color);
}
.control-button:active {
transform: scale(0.95);
}
.description {
max-width: 500px;
text-align: center;
line-height: 1.6;
opacity: 0.8;
font-size: 0.9rem;
}
.code-snippet {
margin-top: 2rem;
background-color: rgba(0, 0, 0, 0.3);
padding: 1rem;
border-radius: 4px;
max-width: 500px;
overflow-x: auto;
}
code {
font-family: 'Courier New', Courier, monospace;
font-size: 0.9rem;
color: #64dfdf;
}
---js
// Get our elements
const box = document.querySelector('.animated-box');
const controls = document.querySelectorAll('.control-button');
// Create our animation
const animation = box.animate([
{ transform: 'translateX(0) rotate(0deg)', backgroundColor: '#5e60ce' },
{ transform: 'translateX(200px) rotate(180deg)', backgroundColor: '#64dfdf' },
{ transform: 'translateX(0) rotate(360deg)', backgroundColor: '#5e60ce' }],
{
duration: 2000,
iterations: Infinity,
easing: 'cubic-bezier(.42,0,.58,1)' });
// Set up our animation controls
controls.forEach(button => {
button.addEventListener('click', () => {
const action = button.dataset.action;
if (action === 'play') animation.play();
if (action === 'pause') animation.pause();
if (action === 'reverse') animation.reverse();
if (action === 'slow') animation.playbackRate = 0.5;
if (action === 'normal') animation.playbackRate = 1;
if (action === 'fast') animation.playbackRate = 2;
});
});
Challenge: slide in, then reverse#
Make a box slide in from the left, then add a button that plays it backwards.
- In the Slide in handler, call
box.animate(...)with the keyframestranslateX(-150px)thentranslateX(0), a duration of 800, andfill: 'forwards'. Save the result inanimation. Expected: the box slides in from the left and stays put. - In the Reverse handler, call
animation.reverse()if there is an animation. Expected: click it after the slide ends and the box slides back out. Click it halfway through and the box turns around from where it is, rather than jumping to the end first. Click it twice in a row and it flips direction each time. - Look closely when the reverse reaches the left end: with
fill: 'forwards'the box pops back to the right.forwardsonly holds the last keyframe after the animation ends, and a reversed animation ends at its first keyframe. Changefillto'both', which holds both ends, and run it again. Expected: the box stays at the left after sliding out.
---html
<div class="stage"><div class="box"></div></div>
<button id="slide">Slide in</button>
<button id="back">Reverse</button>
---css
.stage { height: 70px; border-bottom: 2px dashed #888; margin-bottom: 1rem; overflow: hidden; }
.box { width: 50px; height: 50px; margin: 10px 0 0 160px; background: #5e60ce; border-radius: 4px; }
---js
const box = document.querySelector('.box');
let animation;
document.querySelector('#slide').onclick = () => {
// STEP 1 TODO: slide the box in from the left.
// Keyframes: translateX(-150px) -> translateX(0). Try duration 800, fill: 'forwards'.
// Store the result in `animation`.
};
document.querySelector('#back').onclick = () => {
// STEP 2 TODO: if there is an animation, call animation.reverse()
};
Reveal on scroll: IntersectionObserver#
A very common effect: content fades in as you scroll down to it. Before the browser had a tool for this, you listened to every scroll event and measured every element yourself:
// The old way
window.addEventListener('scroll', () => {
document.querySelectorAll('.animate-on-scroll').forEach(el => {
const top = el.getBoundingClientRect().top;
if (top < window.innerHeight) reveal(el);
});
});
That function runs on every scroll step, many times a second. It measures every element each time, including ones you already revealed, and each measurement can force the browser to recalculate layout. It works, but the page does a lot of checking while it scrolls.
An IntersectionObserver turns it around. You tell the browser which elements to watch, and it calls you only when one of them starts or stops overlapping the visible part of the page (the viewport). Your code runs when something changes, not constantly.
First, hide the elements in CSS so they do not flash on screen before they animate. We hide them only once JavaScript is running, by putting a js class on the <html> element. If the script never loads, or the observer fails, the content stays visible instead of staying invisible for good:
.js .animate-on-scroll { opacity: 0; }
document.documentElement.classList.add('js');
Then:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// The element has become visible enough
entry.target.animate([
{ transform: 'translateY(50px)', opacity: 0 },
{ transform: 'translateY(0)', opacity: 1 }
], {
duration: 500,
fill: 'forwards',
easing: 'ease-out'
});
// Reveal once, then stop watching this element
observer.unobserve(entry.target);
}
});
}, {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
});
document.querySelectorAll('.animate-on-scroll').forEach(el => {
observer.observe(el);
});
The callback gets a list of entries, one per element whose visibility changed. entry.isIntersecting says whether it is now visible, and entry.target is the element. The two options at the bottom control when that happens:
thresholdis the share of the element that must be visible before the callback runs, from 0 to 1.0.1means 10%. The default, 0, means as soon as a single pixel shows.rootMargingrows or shrinks the box used for the test, written like a CSS margin (top, right, bottom, left).'0px 0px -50px 0px'pulls the bottom edge up by 50px, so an element must be 50px inside the screen before it counts as visible. A positive value would start things early instead.
Remember fill: 'forwards' from earlier: without it the element would snap back to opacity 0 as soon as the animation ended.
The demo below uses the same pattern with data attributes for the animation type and delay. Scroll the page inside it and watch each section appear. It has no scroll event handler anywhere.
---html
<header>
<h1>Scroll-Triggered Animations</h1>
</header>
<div class="container">
<div class="scroll-guide">
<p>↓ Scroll Down ↓</p>
</div>
<section class="section scroll-animate" data-animation="fade-in">
<h2>Fade In Animation</h2>
<p>This section uses a simple fade-in animation triggered by the IntersectionObserver when it enters the viewport.</p>
<p>The Web Animations API combined with IntersectionObserver creates efficient scroll-based animations without the need for scroll event listeners.</p>
</section>
<section class="section scroll-animate" data-animation="slide-up" data-delay="200">
<h2>Slide Up Animation</h2>
<p>This section slides up while fading in, with a slight delay of 200ms.</p>
<p>Using data attributes allows us to configure animations declaratively in the HTML while keeping the animation logic in JavaScript.</p>
<div class="code-container">
<code>
// Scroll animation with IntersectionObserver<br>
const observer = new IntersectionObserver((entries) => {<br>
entries.forEach(entry => {<br>
if (entry.isIntersecting) {<br>
// Animate the element<br>
entry.target.classList.add('is-visible');<br>
}<br>
});<br>
});<br>
</code>
</div>
</section>
<div class="card-container">
<div class="card scroll-animate" data-animation="scale-in">
<h3 class="card-title">Scale Animation</h3>
<p>This card scales and fades in when it enters the viewport.</p>
</div>
<div class="card scroll-animate" data-animation="scale-in" data-delay="100">
<h3 class="card-title">Delayed Scale</h3>
<p>This card has a 100ms delay before animating.</p>
</div>
<div class="card scroll-animate" data-animation="scale-in" data-delay="200">
<h3 class="card-title">Staggered Animation</h3>
<p>Staggered delays create a sequential appearance effect.</p>
</div>
</div>
<section class="section scroll-animate" data-animation="fade-in">
<h2>Animation Configuration</h2>
<p>We can configure different aspects of our animations:</p>
<ul>
<li>Animation type via data-animation attribute</li>
<li>Delay via data-delay attribute</li>
<li>Custom thresholds in the IntersectionObserver options</li>
<li>Respecting user preferences for reduced motion</li>
</ul>
</section>
</div>
<footer>
<p>Scroll back up to see the animations again!</p>
</footer>
<button class="reduced-motion-toggle">Toggle Reduced Motion</button>
---css
:root {
--primary-color: #5e60ce;
--secondary-color: #64dfdf;
--accent-color: #ff6b6b;
--dark-color: #222;
--light-color: #f8f9fa;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--dark-color);
color: var(--light-color);
margin: 0;
padding: 0;
line-height: 1.6;
}
header {
background-color: rgba(0, 0, 0, 0.5);
padding: 2rem;
text-align: center;
position: sticky;
top: 0;
z-index: 10;
}
h1 {
margin: 0;
font-weight: 300;
color: var(--secondary-color);
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 1rem;
}
.scroll-guide {
text-align: center;
padding: 2rem;
animation: bounce 2s infinite;
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-20px);
}
60% {
transform: translateY(-10px);
}
}
.section {
margin: 8rem 0;
padding: 2rem;
border-radius: 8px;
background-color: rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
opacity: 0; /* Start invisible */
}
.card-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin: 8rem 0;
}
.card {
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
opacity: 0; /* Start invisible */
}
.card-title {
color: var(--secondary-color);
margin-top: 0;
}
/* Animation types - these will be applied via JS */
.scroll-animate.is-visible {
opacity: 1;
}
footer {
text-align: center;
padding: 2rem;
margin-top: 4rem;
background-color: rgba(0, 0, 0, 0.3);
}
.reduced-motion-toggle {
position: fixed;
bottom: 20px;
right: 20px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 4px;
padding: 10px 15px;
cursor: pointer;
z-index: 100;
}
/* Code display */
.code-container {
margin-top: 2rem;
background-color: rgba(0, 0, 0, 0.3);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
}
code {
font-family: 'Courier New', Courier, monospace;
font-size: 0.9rem;
color: #64dfdf;
}
@media (prefers-reduced-motion: reduce) {
.reduced-motion {
transition: none !important;
animation: none !important;
}
}
---js
// Set up our intersection observer for scroll animations
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
// When element comes into view
if (entry.isIntersecting) {
// Add class that triggers visibility
entry.target.classList.add('is-visible');
// Get the animation type from data attribute
const animationType = entry.target.dataset.animation;
const animationDelay = entry.target.dataset.delay || 0;
// Check if reduced motion is preferred
const reducedMotion = document.body.classList.contains('reduced-motion') ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// If reduced motion is preferred, only change opacity
if (reducedMotion) {
entry.target.animate([
{ opacity: 0 },
{ opacity: 1 }],
{
duration: 300,
fill: 'forwards' });
return;
}
// Create different animations based on the type
let keyframes;
switch (animationType) {
case 'fade-in':
keyframes = [
{ opacity: 0 },
{ opacity: 1 }];
break;
case 'slide-up':
keyframes = [
{ transform: 'translateY(50px)', opacity: 0 },
{ transform: 'translateY(0)', opacity: 1 }];
break;
case 'scale-in':
keyframes = [
{ transform: 'scale(0.8)', opacity: 0 },
{ transform: 'scale(1)', opacity: 1 }];
break;
default:
keyframes = [
{ opacity: 0 },
{ opacity: 1 }];}
// Apply the animation
entry.target.animate(keyframes, {
duration: 600,
easing: 'ease-out',
fill: 'forwards',
delay: parseInt(animationDelay) });
}
});
}, {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px' });
// Start observing elements
document.querySelectorAll('.scroll-animate').forEach(el => {
observer.observe(el);
});
// Reduced motion toggle
const reduceMotionToggle = document.querySelector('.reduced-motion-toggle');
reduceMotionToggle.addEventListener('click', () => {
document.body.classList.toggle('reduced-motion');
// Reset animations by removing the visible class from all elements
document.querySelectorAll('.scroll-animate').forEach(el => {
el.classList.remove('is-visible');
});
// Force a reflow to trigger new animations on scroll
window.scrollBy(0, 1);
window.scrollBy(0, -1);
});
Respect reduced motion#
Some people get dizzy or distracted by movement and turn on "reduce motion" in their system settings. Browsers pass that on. In CSS, @media (prefers-reduced-motion: reduce) can switch off CSS animations, but it does not touch animations you start from JavaScript with animate(). For those you ask the browser yourself:
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
matches is true when the reader asked for less motion. Use it to swap the movement for something gentler, which is what the demo does with its Toggle Reduced Motion button: a plain fade instead of a slide.
const keyframes = reduceMotion
? [{ opacity: 0 }, { opacity: 1 }]
: [{ transform: 'translateY(50px)', opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }];
element.animate(keyframes, { duration: reduceMotion ? 200 : 500, fill: 'forwards' });
This reads the setting once. If the reader changes it while the page is open, nothing updates until the next check. Handling that is a small addition, and the challenge in Beyond Media Queries has you do it.
Keeping it smooth#
When the browser shows a change, it can do up to three kinds of work, in order:
- Layout works out the size and position of elements. Change
width,height,toporleftand neighbors may have to move, so this is the expensive step. - Paint fills in the pixels of each element.
- Compositing stacks finished layers on top of each other and puts them on screen. Many browsers can do this part on the graphics hardware, without bothering the main JavaScript thread.
transform and opacity can usually be animated with compositing alone, so the page keeps its layout untouched. left and top start the chain over at layout on every frame.
Here is the same sideways motion done both ways, with a frame counter. Press each button and watch the number.
---html
<button id="left">Animate left</button>
<button id="transform">Animate transform</button>
<button id="stop">Stop</button>
<p id="fps">FPS: press a button</p>
<div id="stage"></div>
---css
#stage { position: relative; height: 120px; overflow: hidden; border: 1px solid #888; }
.dot { position: absolute; width: 10px; height: 10px; background: #5e60ce; }
---js
const N = 200; // TODO: raise to 5000, press each button again, compare the FPS
const stage = document.querySelector('#stage');
const fpsOut = document.querySelector('#fps');
let animations = [];
function start(property) {
animations.forEach(a => a.cancel());
stage.innerHTML = '';
animations = [];
for (let i = 0; i < N; i++) {
const dot = document.createElement('div');
dot.className = 'dot';
dot.style.top = (Math.floor(i / 20) % 10) * 12 + 'px';
dot.style.left = (i % 20) * 12 + 'px';
stage.appendChild(dot);
const to = property === 'left' ? { left: '250px' } : { transform: 'translateX(250px)' };
const from = property === 'left' ? { left: (i % 20) * 12 + 'px' } : { transform: 'translateX(0)' };
animations.push(dot.animate([from, to], { duration: 1000, iterations: Infinity, direction: 'alternate' }));
}
}
// Count how many frames the page manages to draw each second
let frames = 0, last = performance.now();
function tick(now) {
frames++;
if (now - last >= 1000) {
fpsOut.textContent = 'FPS: ' + Math.round(frames * 1000 / (now - last));
frames = 0; last = now;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
document.querySelector('#left').onclick = () => start('left');
document.querySelector('#transform').onclick = () => start('transform');
document.querySelector('#stop').onclick = () => animations.forEach(a => a.cancel());
At 200 boxes on a fast computer, both buttons will probably show the same frame rate, and that is a fair result: the cost is real but small until there is more to lay out. To see the difference, change N to 5000, or open your browser's DevTools, go to the Performance tab, set CPU throttling to 6x slowdown, record two seconds of each button, and look for purple Layout entries. The left animation produces layout work on every frame. The transform animation produces almost none. (In a headless Chrome check while revising this post, a two-second window showed about 150 layouts for left and 2 for transform; the frame rates were the same at 200 boxes and both dropped at 5,000, so measure on your own device rather than trusting a rule.)
Two more habits:
will-changeis a CSS hint such aswill-change: transform;that tells the browser ahead of time which property is about to change, so it can prepare. It costs memory, so add it only to elements that really will animate, and remove it afterwards. For a plaintransformoropacityanimation the browser usually prepares on its own, so you will often not need it.- Measure on a real device. Chrome's DevTools Performance panel shows layout, paint and frames directly. Guessing about speed is how people end up optimizing the wrong thing.
Going further#
The two demos below combine the pieces. The first drives a bouncing ball with requestAnimationFrame and simple physics, because gravity needs new math every frame, which is the case where hand-written frames beat animate(). The math uses the time since the last frame, so a 144 Hz screen and a 60 Hz one see the same fall; adding a fixed amount per frame would make the faster screen drop the ball faster. It also shows a live frame rate. Try changing the gravity and friction sliders.
---html
<header>
<h1>Physics-Based Animations</h1>
<p class="subtitle">Using requestAnimationFrame</p>
</header>
<div class="demo-container">
<div class="physics-stage">
<div class="ball"></div>
<div class="floor"></div>
</div>
<div class="controls">
<button id="start-btn">Drop Ball</button>
<button id="reset-btn">Reset</button>
</div>
<div class="settings">
<div class="setting-group">
<label for="gravity">Gravity Strength</label>
<input type="range" id="gravity" min="0.1" max="1.0" step="0.1" value="0.5">
</div>
<div class="setting-group">
<label for="friction">Bounce Friction</label>
<input type="range" id="friction" min="0.5" max="0.95" step="0.05" value="0.8">
</div>
</div>
<div class="performance-container">
<h3>Performance Metrics</h3>
<div class="performance-meter">FPS: -- (--ms)</div>
<div class="fps-graph"></div>
</div>
<div class="info">
<p>Click the ball or use the "Drop Ball" button to start the animation. This demo uses requestAnimationFrame for smooth animation and implements a simple physics engine for realistic bouncing.</p>
<p>The frame rate is measured from the time between animation frames, so a dip means the browser could not keep up.</p>
</div>
<div class="code-snippet">
<code>
// Physics animation with requestAnimationFrame<br>
function animate(timestamp) {<br>
const dt = (timestamp - last) / 1000;<br>
last = timestamp;<br>
// Apply gravity for the time that passed<br>
velocity += gravity * dt;<br>
<br>
// Update position<br>
position += velocity * dt;<br>
<br>
// Check for collision with floor<br>
if (position >= floorPosition) {<br>
position = floorPosition;<br>
velocity = -velocity * friction;<br>
}<br>
<br>
// Apply position to element<br>
ball.style.transform = `translateY(${position}px)`;<br>
<br>
// Continue animation<br>
requestAnimationFrame(animate);<br>
}
</code>
</div>
</div>
---css
:root {
--primary-color: #5e60ce;
--secondary-color: #64dfdf;
--accent-color: #ff6b6b;
--dark-color: #222;
--light-color: #f8f9fa;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--dark-color);
color: var(--light-color);
margin: 0;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
}
header {
text-align: center;
margin-bottom: 2rem;
}
h1 {
color: var(--secondary-color);
font-weight: 300;
margin-bottom: 0.5rem;
}
.subtitle {
opacity: 0.8;
margin-top: 0;
}
.demo-container {
width: 100%;
max-width: 800px;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
padding: 2rem;
position: relative;
}
.physics-stage {
height: 400px;
position: relative;
border-bottom: 2px solid rgba(255, 255, 255, 0.2);
margin-bottom: 1.5rem;
overflow: hidden;
}
.ball {
width: 60px;
height: 60px;
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
border-radius: 50%;
position: absolute;
top: 0;
left: calc(50% - 30px);
cursor: pointer;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
.ball::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 50%;
top: 10px;
left: 10px;
}
.floor {
position: absolute;
bottom: 0;
width: 100%;
height: 10px;
background-color: rgba(255, 255, 255, 0.1);
}
.controls {
display: flex;
gap: 15px;
margin-bottom: 1.5rem;
}
button {
padding: 8px 15px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: var(--secondary-color);
}
.settings {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 1.5rem;
}
.setting-group {
display: flex;
flex-direction: column;
}
label {
margin-bottom: 5px;
font-size: 0.9rem;
opacity: 0.9;
}
input[type="range"] {
width: 150px;
}
.performance-container {
background-color: rgba(0, 0, 0, 0.3);
padding: 1rem;
border-radius: 4px;
margin-top: 1.5rem;
}
.performance-meter {
font-family: 'Courier New', Courier, monospace;
font-size: 0.9rem;
}
.fps-graph {
height: 50px;
width: 100%;
background-color: rgba(0, 0, 0, 0.2);
position: relative;
margin-top: 10px;
}
.fps-bar {
position: absolute;
bottom: 0;
width: 2px;
background-color: var(--primary-color);
}
.info {
margin-top: 2rem;
font-size: 0.9rem;
opacity: 0.8;
line-height: 1.6;
}
.code-snippet {
background-color: rgba(0, 0, 0, 0.3);
padding: 1rem;
border-radius: 4px;
margin-top: 1.5rem;
overflow-x: auto;
}
code {
font-family: 'Courier New', Courier, monospace;
font-size: 0.9rem;
color: #64dfdf;
}
---js
// Physics-based bouncing animation using requestAnimationFrame
const ball = document.querySelector('.ball');
const startBtn = document.getElementById('start-btn');
const resetBtn = document.getElementById('reset-btn');
const gravityInput = document.getElementById('gravity');
const frictionInput = document.getElementById('friction');
const performanceMeter = document.querySelector('.performance-meter');
const fpsGraph = document.querySelector('.fps-graph');
// Physics variables
let velocity = 0;
let position = 0;
let gravity = parseFloat(gravityInput.value);
let friction = parseFloat(frictionInput.value);
const floorPosition = 340; // Adjusted for ball size
let animating = false;
let animationId = null;
// Performance tracking
const fpsValues = [];
const maxFpsFrames = 60;
function startAnimation() {
if (!animating) {
animating = true;
position = 0;
velocity = 0;
// Clear previous animation frame if any
if (animationId) {
cancelAnimationFrame(animationId);
}
// Start our animation loop
lastTime = null;
animationId = requestAnimationFrame(animate);
}
}
let lastTime = null;
function animate(timestamp) {
// Seconds since the last frame, so a 144 Hz screen falls like a 60 Hz one.
// The cap stops a long pause (a hidden tab) from launching the ball.
if (lastTime === null) lastTime = timestamp;
const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
lastTime = timestamp;
// The slider is pixels per frame at 60 fps; convert it to pixels per second
velocity += gravity * 3600 * dt;
// Update position based on velocity
position += velocity * dt;
// Check for collision with floor
if (position >= floorPosition) {
position = floorPosition;
// Bounce with energy loss (friction)
velocity = -velocity * friction;
// Stop animation if ball is almost at rest
if (Math.abs(velocity) < 30) {
animating = false;
return;
}
}
// Apply position to the ball
ball.style.transform = `translateY(${position}px)`;
// Continue animation loop
animationId = requestAnimationFrame(animate);
}
// Measure how often the browser actually draws a frame
let lastFrameTime = performance.now();
function logPerformance(now) {
const frameTime = now - lastFrameTime;
lastFrameTime = now;
if (frameTime > 0) {
const fps = Math.round(1000 / frameTime);
performanceMeter.textContent = `FPS: ${fps} (${frameTime.toFixed(1)}ms between frames)`;
updateFpsGraph(fps);
}
requestAnimationFrame(logPerformance);
}
function updateFpsGraph(fps) {
// Add to array, limiting size
fpsValues.push(fps);
if (fpsValues.length > maxFpsFrames) {
fpsValues.shift();
}
// Clear graph
fpsGraph.innerHTML = '';
// Add bars
fpsValues.forEach((fps, index) => {
const bar = document.createElement('div');
bar.className = 'fps-bar';
// Scale height (60fps = full height)
const height = Math.min(fps / 60 * 100, 100);
// Position and style
bar.style.height = `${height}%`;
bar.style.left = `${index / maxFpsFrames * 100}%`;
// Color based on performance
if (fps >= 55) {
bar.style.backgroundColor = '#4ade80'; // Green
} else if (fps >= 30) {
bar.style.backgroundColor = '#facc15'; // Yellow
} else {
bar.style.backgroundColor = '#f87171'; // Red
}
fpsGraph.appendChild(bar);
});
}
// Event listeners
startBtn.addEventListener('click', startAnimation);
ball.addEventListener('click', startAnimation);
resetBtn.addEventListener('click', () => {
// Cancel animation
if (animationId) {
cancelAnimationFrame(animationId);
}
// Reset state
animating = false;
position = 0;
velocity = 0;
// Reset ball position
ball.style.transform = 'translateY(0)';
// Clear FPS values
fpsValues.length = 0;
updateFpsGraph(0);
});
gravityInput.addEventListener('input', () => {
gravity = parseFloat(gravityInput.value);
});
frictionInput.addEventListener('input', () => {
friction = parseFloat(frictionInput.value);
});
// Start frame-rate monitoring
requestAnimationFrame(logPerformance);
The second runs several elements in sequence. animate() returns an object with a finished promise, which is what lets await wait for one animation before starting the next. Switch between sequential, staggered and cascade to see how the timing relationship changes the effect.
---html
<header>
<h1>Coordinated Animation Sequences</h1>
<p class="subtitle">Using Promises and async/await with Web Animations API</p>
</header>
<div class="demo-container">
<div class="card-container">
<div class="sequence-card">1</div>
<div class="sequence-card">2</div>
<div class="sequence-card">3</div>
<div class="sequence-card">4</div>
<div class="sequence-card">5</div>
</div>
<div class="sequence-option">
<p>Animation Sequence Type:</p>
<div class="radio-group">
<label>
<input type="radio" name="sequence-type" value="sequential" checked>
Sequential
</label>
<label>
<input type="radio" name="sequence-type" value="staggered">
Staggered
</label>
<label>
<input type="radio" name="sequence-type" value="cascade">
Cascade
</label>
</div>
</div>
<div class="controls">
<button class="sequence-button">Run Animation</button>
</div>
<div class="code-container">
<code>
// Animation factory function<br>
function animateElement(element, keyframes, options) {<br>
return new Promise(resolve => {<br>
const animation = element.animate(keyframes, options);<br>
animation.onfinish = resolve;<br>
});<br>
}<br><br>
// Sequential animation with async/await<br>
async function runSequentialAnimation() {<br>
for (const element of elements) {<br>
await animateElement(element, keyframes, options);<br>
}<br>
}
</code>
</div>
<div class="explanation">
<p>This demo showcases how to create coordinated animation sequences using Promises and async/await with the Web Animations API. The three sequence types demonstrate different approaches to animation choreography:</p>
<ul>
<li><strong>Sequential:</strong> Each card animates one after the other, waiting for the previous animation to complete.</li>
<li><strong>Staggered:</strong> All cards animate simultaneously but with increasing delays.</li>
<li><strong>Cascade:</strong> Each card starts animating shortly after the previous one starts, creating an overlapping effect.</li>
</ul>
</div>
</div>
---css
:root {
--primary-color: #5e60ce;
--secondary-color: #64dfdf;
--tertiary-color: #7bdff2;
--dark-color: #222;
--light-color: #f8f9fa;
--card-1-color: #5e60ce;
--card-2-color: #5390d9;
--card-3-color: #4ea8de;
--card-4-color: #48bfe3;
--card-5-color: #56cfe1;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--dark-color);
color: var(--light-color);
margin: 0;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
}
header {
text-align: center;
margin-bottom: 2rem;
}
h1 {
color: var(--secondary-color);
font-weight: 300;
margin-bottom: 0.5rem;
}
.subtitle {
opacity: 0.8;
margin-top: 0;
}
.demo-container {
width: 100%;
max-width: 800px;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
}
.card-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
margin: 2rem 0;
}
.sequence-card {
width: 120px;
height: 170px;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
font-size: 24px;
font-weight: bold;
color: white;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
opacity: 0;
transform: translateY(20px) scale(0.95);
}
.sequence-card:nth-child(1) {
background-color: var(--card-1-color);
}
.sequence-card:nth-child(2) {
background-color: var(--card-2-color);
}
.sequence-card:nth-child(3) {
background-color: var(--card-3-color);
}
.sequence-card:nth-child(4) {
background-color: var(--card-4-color);
}
.sequence-card:nth-child(5) {
background-color: var(--card-5-color);
}
.controls {
display: flex;
gap: 15px;
margin-top: 1rem;
}
button {
padding: 10px 20px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.2s, transform 0.1s;
min-width: 140px;
}
button:hover {
background-color: var(--secondary-color);
}
button:active {
transform: scale(0.98);
}
button:disabled {
background-color: rgba(94, 96, 206, 0.5);
cursor: not-allowed;
}
.sequence-option {
margin-top: 2rem;
display: flex;
flex-direction: column;
gap: 10px;
}
.radio-group {
display: flex;
gap: 15px;
}
.radio-group label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.code-container {
margin-top: 2rem;
background-color: rgba(0, 0, 0, 0.3);
padding: 1rem;
border-radius: 4px;
width: 100%;
overflow-x: auto;
}
code {
font-family: 'Courier New', Courier, monospace;
font-size: 0.9rem;
color: #64dfdf;
}
.explanation {
margin-top: 2rem;
font-size: 0.9rem;
line-height: 1.6;
opacity: 0.8;
}
---js
// Coordinated animation sequence using Promises
const cards = document.querySelectorAll('.sequence-card');
const sequenceButton = document.querySelector('.sequence-button');
const sequenceOptions = document.querySelectorAll('input[name="sequence-type"]');
// Animation factory function
function animateElement(element, keyframes, options) {
return new Promise(resolve => {
const animation = element.animate(keyframes, options);
animation.onfinish = resolve;
});
}
// Get selected sequence type
function getSequenceType() {
for (const option of sequenceOptions) {
if (option.checked) {
return option.value;
}
}
return 'sequential'; // Default
}
// Sequential animation - each animation waits for the previous
async function runSequentialAnimation() {
for (const card of cards) {
// Reset card
card.style.opacity = '0';
card.style.transform = 'translateY(20px) scale(0.95)';
// Animate card
await animateElement(card, [
{ opacity: 0, transform: 'translateY(20px) scale(0.95)' },
{ opacity: 1, transform: 'translateY(0) scale(1)' }],
{
duration: 400,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards' });
// Pause between cards
await new Promise(r => setTimeout(r, 100));
}
}
// Staggered animation - all animate together but with delays
async function runStaggeredAnimation() {
// Reset all cards
for (const card of cards) {
card.style.opacity = '0';
card.style.transform = 'translateY(20px) scale(0.95)';
}
// Create an array of promises for all animations
const animations = Array.from(cards).map((card, index) =>
animateElement(card, [
{ opacity: 0, transform: 'translateY(20px) scale(0.95)' },
{ opacity: 1, transform: 'translateY(0) scale(1)' }],
{
duration: 500,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
delay: index * 150,
fill: 'forwards' }));
// Wait for all animations to complete
await Promise.all(animations);
}
// Cascade animation - each starts before previous completes
async function runCascadeAnimation() {
// Reset all cards
for (const card of cards) {
card.style.opacity = '0';
card.style.transform = 'translateY(20px) scale(0.95)';
}
// Start each animation with a short delay
for (let i = 0; i < cards.length; i++) {
setTimeout(() => {
animateElement(cards[i], [
{ opacity: 0, transform: 'translateY(20px) scale(0.95)' },
{ opacity: 1, transform: 'translateY(0) scale(1)' }],
{
duration: 600,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards' });
}, i * 100);
}
// Wait long enough for all animations to complete
await new Promise(r => setTimeout(r, cards.length * 100 + 600));
}
// Final flourish - animate all cards together
async function runFinalFlourish() {
const animations = Array.from(cards).map((card, i) =>
animateElement(card, [
{ transform: 'rotate(0deg)' },
{ transform: 'rotate(360deg)' }],
{
duration: 600,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
delay: i * 50 }));
await Promise.all(animations);
}
// Create our sequence
async function runSequence() {
// Disable button during animation
sequenceButton.disabled = true;
const sequenceType = getSequenceType();
// Run the appropriate animation sequence
if (sequenceType === 'sequential') {
await runSequentialAnimation();
} else if (sequenceType === 'staggered') {
await runStaggeredAnimation();
} else if (sequenceType === 'cascade') {
await runCascadeAnimation();
}
// Add final flourish
await runFinalFlourish();
// Re-enable button
sequenceButton.disabled = false;
}
// Attach event listener
sequenceButton.addEventListener('click', runSequence);
// Run an initial animation when the page loads
setTimeout(() => {
runStaggeredAnimation();
}, 500);
What you can do now#
You can fade or slide an element with element.animate(), hold its end state with fill, repeat it, and pause, speed up or reverse it from a button. You can start an animation when an element scrolls into view with an IntersectionObserver, without a scroll handler. You can check prefers-reduced-motion before choosing how much to move, and you know why transform and opacity are the safe properties to animate.
One gap is left open. The reduced-motion check above runs once, when the animation starts. A page that adapts while it is open, to a changed setting, a container that gets resized, or touch versus mouse, needs listeners that keep watching. That is what Beyond Media Queries builds.
Comments require JavaScript.