Per-Keyframe Offsets and Easing in the Web Animations API
Part of Web Animations API Integration in Core CSS Animation Fundamentals.
The problem
A “like” button should pop: grow quickly to 130%, undershoot slightly to 95%, and settle at 100%. Written with three evenly spaced keyframes and easing: 'ease-out' in the options, the pop feels mushy — the overshoot happens too late and the settle drags. Adding more keyframes to shape it makes the array long and the result still uneven.
Two things are wrong. The keyframes are evenly spaced because no offsets were given, and the easing in the options applies to the whole animation, not to each segment.
Root cause analysis: two levels of easing and even spacing by default
Offsets default to even spacing. In an array of keyframes without offset values, the browser spaces them evenly between 0 and 1. Three keyframes land at 0, 0.5 and 1. A pop that should reach its peak at 18% of the duration needs offset: 0.18 on that keyframe.
Keyframe easing applies to one interval. An easing property on a keyframe object shapes the progress from that keyframe to the next one. It is the WAAPI equivalent of animation-timing-function inside a CSS @keyframes block. The last keyframe’s easing is unused, because no interval follows it.
Effect easing applies to the whole iteration. The easing in the options object reshapes overall progress before keyframes are sampled. A non-linear effect easing on a multi-keyframe animation distorts the timing of every segment: with ease-out, early segments are compressed and later ones stretched. For multi-step effects, leave effect easing as linear and shape each segment with keyframe easing.
Missing start or end keyframes are implicit. If the first keyframe has no offset: 0 equivalent, the browser uses the element’s current underlying value as the start. That lets a pop animate from whatever scale the element currently has, but it also means a missing end keyframe returns to the underlying value, which may not be what you expect.
Property-indexed format is an alternative. { scale: [1, 1.3, 0.95, 1], offset: [0, 0.18, 0.55, 1], easing: [...] } expresses the same thing with one array per property. It is compact but harder to read when properties do not share offsets.
Step-by-step resolution
Production code pattern
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
function popLike(button) {
if (reduce.matches) {
return button.animate([{ opacity: 0.6 }, { opacity: 1 }], { duration: 120 });
}
return button.animate(
[
{ scale: 1, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' }, // grow fast
{ scale: 1.3, offset: 0.18, easing: 'cubic-bezier(0.4, 0, 0.2, 1)' }, // swing back
{ scale: 0.95, offset: 0.55, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' }, // settle
{ scale: 1 },
],
{ duration: 400, easing: 'linear', id: 'like-pop' } // effect easing stays linear
);
}
// Implicit start: shake from wherever translate currently is.
function shake(field) {
if (reduce.matches) return;
return field.animate(
[
{ translate: '-6px 0', offset: 0.2 },
{ translate: '5px 0', offset: 0.45 },
{ translate: '-3px 0', offset: 0.7 },
{ translate: '0 0' },
],
{ duration: 360, easing: 'linear' }
);
}
/* The CSS equivalent of the pop, for comparison: timing functions inside keyframes. */
@keyframes like-pop {
0% { scale: 1; animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1); }
18% { scale: 1.3; animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }
55% { scale: 0.95; animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1); }
100% { scale: 1; }
}
@media (prefers-reduced-motion: reduce) {
.like.is-popping { animation: none; }
}
Rendering Impact: composite. Offsets and easings change when values are reached, not what is animated; the pop and shake animate only
scaleandtranslate.
The shake omits an offset: 0 keyframe, so its first segment starts from the element’s current translate. If the field is already translated by a layout effect, the shake starts from there rather than jumping to zero first.
Reading keyframes back
animation.effect.getKeyframes() returns the normalised keyframes, including a computedOffset for every keyframe even when offsets were omitted. It is the quickest way to confirm even spacing was not applied where you expected custom offsets, and it works for CSS animations too — a CSSAnimation’s effect returns the keyframes from the @keyframes rule with their timing functions as easing. Combined with the inventory helper in inspecting running animations with getAnimations(), it lets you audit whether script and CSS versions of the same effect match.
Verification checklist
Constraints and trade-offs
- Offsets out of order throw a TypeError when the animation is created.
- Implicit start keyframes depend on the element’s current value, which makes results context-dependent.
- Property-indexed keyframes with different offsets per property are hard to reason about.
linear()easing can encode a whole multi-segment curve in one timing function, an alternative to many keyframes.- Very short segments with strong easing can look like jumps on low frame rates.
Frequently asked questions
How are keyframes spaced if I do not set offsets?
Evenly between 0 and 1, including the first and last. Three keyframes land at 0, 0.5 and 1.
What is the difference between easing in options and easing on a keyframe?
Options easing reshapes progress through the whole iteration. Keyframe easing shapes only the interval from that keyframe to the next one.
Can I leave out the first keyframe?
Yes. The browser uses the element’s current underlying value as the implicit starting point, which is useful for effects that should start from wherever the element is.
How do I see the offsets the browser computed?
Call animation.effect.getKeyframes() and read computedOffset on each keyframe.
Related
- Web Animations API Integration — the parent topic
- linear() Easing Function for Custom Curves — encoding multi-segment timing in one function
- element.animate() vs CSS Keyframes — choosing between the two formats