Web Animations API: element.animate(), Keyframes, Timing and Groups
The Web Animations API lets you drive CSS-like animations from JavaScript with full playback control. Here's how element.animate(), keyframes, and timing actually work.
Why the Web Animations API Exists
CSS animations are great — until they aren't. The moment you need to pause mid-animation, reverse on a gesture, scrub to an arbitrary time, or chain a sequence that depends on runtime data, you're writing convoluted hack-stacks with animationend listeners and setTimeout calls that drift under load. That's exactly the problem the Web Animations API (WAAPI) was designed to fix.
WAAPI shipped in Chrome 36 back in 2014, but it took until Firefox 48 (2016) and then Safari 13.1 (2020) for cross-browser support to be solid enough for production use without a polyfill. In 2026 you can use the full Level 1 spec without worrying — all evergreen browsers have it. The Level 2 additions (GroupEffect, SequenceEffect) are still experimental, so we'll flag those clearly.
Honestly, most developers reach for GSAP or Framer Motion before they even try WAAPI. That's fine for complex timelines, but WAAPI is zero-dependency, runs off the compositor thread by default on transform and opacity, and has a proper promise-based API. If you're building a UI library or a design system, shipping WAAPI-based animations means your users don't have to bundle an extra 30 kB of animation runtime. Empire UI's animation components use WAAPI for exactly that reason.
Worth noting: WAAPI doesn't replace CSS animations everywhere. For simple hover states or pure-CSS keyframes you'd set and forget, CSS is still cleaner. WAAPI shines when you need *control* — the ability to call .play(), .pause(), .reverse(), or .updatePlaybackRate() at any point in the animation lifecycle.
element.animate() — The Core API
element.animate() takes two arguments: a keyframe list and a timing options object. It returns an Animation instance immediately and starts playing. That's it. But the details of both arguments matter a lot.
const el = document.querySelector('.card');
const animation = el.animate(
// Keyframe list — same properties as CSS @keyframes
[
{ opacity: 0, transform: 'translateY(24px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
// Timing options
{
duration: 400, // ms
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'forwards', // keep final state after animation ends
delay: 100,
}
);
// animation is an Animation object — you own it now
animation.finished.then(() => console.log('done'));A couple of things trip people up here. First, duration is in milliseconds — there's no CSS-style 400ms string. Second, fill: 'forwards' is the equivalent of animation-fill-mode: forwards in CSS, and you almost always want it when the animation moves an element to a final resting position. Without it, the element snaps back to its original state the moment the animation finishes. Third, the returned Animation object has a .finished promise, a .ready promise, and an .onfinish callback — pick whichever fits your async pattern.
The keyframe list can also be written as an object with arrays instead of an array of objects, which is useful when you have lots of properties sharing the same offset structure:
// Alternative keyframe format
el.animate(
{
opacity: [0, 0.5, 1],
transform: ['scale(0.8)', 'scale(1.05)', 'scale(1)'],
// Custom offsets per property not supported in this format —
// use the array-of-objects form for that.
},
{ duration: 500, easing: 'ease-out', fill: 'forwards' }
);Keyframes in Depth: Offsets, Composite, and Pseudo-Elements
By default, keyframes are evenly distributed across the duration. Two keyframes = 0% and 100%. Three keyframes = 0%, 50%, 100%. You can override that with the offset property, which accepts a float from 0 to 1:
el.animate(
[
{ transform: 'translateX(0)', offset: 0 },
{ transform: 'translateX(80px)', offset: 0.3 }, // 30% in
{ transform: 'translateX(60px)', offset: 0.6 }, // overshoot settle
{ transform: 'translateX(80px)', offset: 1 },
],
{ duration: 600, fill: 'forwards' }
);The composite property on each keyframe controls how the value combines with any existing animation running on the same element. The default is 'replace', which just overwrites. 'add' stacks transforms so translateX(20px) on top of an existing translateX(40px) gives you 60px — really useful for layering entrance animations over idle loops. 'accumulate' is similar but does proper mathematical accumulation for things like rotations. In practice, 'add' is the one you'll actually use.
Quick aside: element.animate() works on pseudo-elements too — document.querySelector('.card::before') doesn't work (you can't query pseudo-elements in the DOM), but you can target them by passing a pseudoElement option in the timing object. It's part of the spec but browser support is patchy as of late 2026, so test before shipping.
One more thing — easing can be specified per keyframe, not just globally. This lets you use a sharp ease-in between keyframes 1 and 2, then an ease-out between 2 and 3, inside a single animate() call. That used to require either multiple chained CSS animations or GSAP.
Timing Options: delay, endDelay, iterations, direction, fill
The timing object is where most of the power lives. Here's a rundown of the options you'll actually use, with the gotchas called out:
el.animate(keyframes, {
duration: 400, // ms — required if you want a timed animation
delay: 200, // wait before starting
endDelay: 100, // wait after finishing (useful in sequences)
iterations: Infinity, // loops — or a finite number
direction: 'alternate', // 'normal' | 'reverse' | 'alternate' | 'alternate-reverse'
fill: 'both', // 'none' | 'forwards' | 'backwards' | 'both'
easing: 'ease-in-out',
playbackRate: 1, // set to 0.5 for half speed, -1 to play backwards
});The fill: 'backwards' value is the one that catches people — it applies the *first* keyframe's styles during the delay period, so the element doesn't sit at its natural state before the animation kicks in. fill: 'both' does backwards during delay and forwards after end. For UI work you usually want 'forwards' or 'both'.
Look, iterations: Infinity is the correct way to do an infinite loop — but you need to be careful with fill. fill: 'forwards' on an infinite animation is meaningless because the animation never ends, but some browsers will still hold a compositing layer for the fill state. Set fill: 'none' on infinite loops. Also worth knowing: if you want to smoothly exit an infinite loop, call animation.cancel() rather than animation.finish() — finish() on an infinite animation throws a DOMException.
If you're building interactive animations where the user can drag or scrub, set playbackRate: 0 to freeze the animation, then use animation.currentTime = someValue to set the position manually. This is the foundation of scroll-linked animations — a pattern that Empire UI's aurora and vaporwave motion components use internally.
Playback Control: play, pause, reverse, cancel, finish
Once you have an Animation object, you've got a real playback API. This is the whole point.
const anim = el.animate(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 300, fill: 'forwards' }
);
// Pause immediately (before it even renders a frame)
anim.pause();
// Later, on user interaction:
document.querySelector('.btn').addEventListener('click', () => {
// Toggle play/pause
if (anim.playState === 'running') {
anim.pause();
} else {
anim.play();
}
});
// Reverse direction at current position
anim.reverse();
// Jump to 150ms into the animation
anim.currentTime = 150;
// Speed up to 2x
anim.updatePlaybackRate(2);
// Promise-based completion
await anim.finished;
console.log('Animation complete');The playState property tells you where you are: 'idle', 'running', 'paused', or 'finished'. Check this before calling .play() — calling .play() on a 'finished' animation rewinds it to the start and replays, which is usually not what you want after a one-shot entrance animation.
One real-world pattern: building a button that animates on hover and reverses on mouse-out, regardless of where in the animation the user's cursor is when they leave. CSS handles this poorly — the reverse transition always starts from the beginning. With WAAPI:
let anim = null;
el.addEventListener('mouseenter', () => {
if (!anim) {
anim = el.animate(
[{ transform: 'scale(1)' }, { transform: 'scale(1.06)' }],
{ duration: 200, fill: 'forwards', easing: 'ease-out' }
);
} else {
anim.playbackRate = 1; // forward
anim.play();
}
});
el.addEventListener('mouseleave', () => {
if (anim) {
anim.playbackRate = -1; // reverse from current position
anim.play();
}
});This pattern gives you a perfectly smooth reverse from whatever point the animation was at. No CSS transition hackery, no tracking state manually. It works especially well on cards and buttons — try layering it on top of Empire UI's glassmorphism components for a tactile feel.
Sequencing Animations Without Libraries
The finished promise is your sequencing primitive. Chain it with await in an async function and you've got a proper animation timeline:
async function entranceSequence(container) {
const logo = container.querySelector('.logo');
const headline = container.querySelector('h1');
const cta = container.querySelector('.cta');
// Fade in logo
await logo.animate(
[{ opacity: 0, transform: 'translateY(-12px)' }, { opacity: 1, transform: 'none' }],
{ duration: 400, easing: 'ease-out', fill: 'forwards' }
).finished;
// Then slide in headline (no delay needed — sequenced by await)
await headline.animate(
[{ opacity: 0, transform: 'translateX(-20px)' }, { opacity: 1, transform: 'none' }],
{ duration: 350, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', fill: 'forwards' }
).finished;
// Then pop the CTA
await cta.animate(
[{ opacity: 0, transform: 'scale(0.9)' }, { opacity: 1, transform: 'scale(1)' }],
{ duration: 300, easing: 'ease-out', fill: 'forwards' }
).finished;
}
entranceSequence(document.querySelector('.hero'));For parallel animations — things that should start at the same time — just don't await them individually. Kick them all off and then await Promise.all():
async function parallelFadeIn(items) {
const animations = items.map((el, i) =>
el.animate(
[{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }],
{
duration: 400,
delay: i * 60, // stagger by 60ms per item
easing: 'ease-out',
fill: 'forwards',
}
)
);
await Promise.all(animations.map(a => a.finished));
console.log('All items visible');
}The stagger pattern above — delay: i * 60 — is one of the most useful UI animation techniques you can have in your toolkit. 60ms per item is a good starting point for lists. Go below 40ms and it reads as simultaneous. Go above 100ms and it starts feeling sluggish on long lists. That said, always gate staggered animations behind prefers-reduced-motion: no-preference — for users who've turned off motion, zero delay and a simple opacity fade is far better than skipping the animation entirely.
Level 2 of the spec adds GroupEffect and SequenceEffect as first-class objects for building these kinds of timelines declaratively, but browser support in 2026 is still Chrome-only behind a flag. The await-chain approach above is what you'd actually ship today.
Performance: What Runs on the Compositor
The performance story for WAAPI is the same as CSS animations: only transform and opacity are guaranteed to run off the main thread on the compositor. Animating width, height, top, left, margin, padding, border-radius, or background-color will trigger layout or paint on every frame, which will kill performance on any device below a high-end laptop.
Here's how to check if your animation is compositor-eligible at runtime:
const anim = el.animate(
[{ transform: 'translateX(0)' }, { transform: 'translateX(200px)' }],
{ duration: 1000, fill: 'forwards' }
);
const effects = anim.effect.getComputedTiming();
console.log(effects); // timing info
// Check if it's on the compositor:
// Open Chrome DevTools > Performance tab > record the animation
// Compositor thread animations show as green in the timeline.
// Or use the Animations panel to see which properties are accelerated.In practice, you should do two things: always use transform instead of positional properties (translate via transform: translateX() not left: Xpx), and add will-change: transform or will-change: opacity to elements you're about to animate. The will-change hint tells the browser to promote the element to its own compositor layer ahead of time, so the first frame isn't janky. Remove it after the animation finishes — leaving will-change: transform on a static element wastes GPU memory for no reason.
async function animateWithHint(el) {
// Promote before animation
el.style.willChange = 'transform, opacity';
await el.animate(
[{ opacity: 0, transform: 'scale(0.95)' }, { opacity: 1, transform: 'scale(1)' }],
{ duration: 350, easing: 'ease-out', fill: 'forwards' }
).finished;
// Clean up after — don't leave will-change sitting there
el.style.willChange = 'auto';
}One more thing — if you're using WAAPI to power scroll-linked animations or hover effects on a lot of elements simultaneously (like staggering 50+ list items), profile before assuming it's fast. WAAPI doesn't magically make layout-triggering animations cheap. The gradient generator and box shadow generator tools on Empire UI are built with this compositor discipline — all their real-time preview updates animate only transform and opacity, which is why they stay smooth even on mid-range hardware.
FAQ
CSS animations are declarative and static — you define them in a stylesheet and they run. WAAPI gives you a JavaScript object you can play, pause, reverse, and scrub at any time. Use CSS for simple fire-and-forget transitions, WAAPI when you need runtime control.
Yes, the Level 1 spec (element.animate(), Animation object, keyframes, timing) has full support in Chrome, Firefox, and Safari as of 2020. Level 2 features like GroupEffect and SequenceEffect are still experimental and Chrome-only. You don't need a polyfill for production use in 2026.
You're missing fill: 'forwards' in the timing options. Without it, the browser removes the animation's styles the moment it finishes and the element returns to its natural state. Set fill: 'forwards' or fill: 'both' to keep the final keyframe applied.
You can, but they won't run on the compositor — they'll trigger repaint on every frame and hurt performance on lower-end hardware. Stick to transform and opacity for smooth 60fps animations. Use CSS transitions for color or border-radius changes, since those are less performance-critical and rarely need JavaScript playback control.