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.

The like-button pop with offsets and per-segment easingUneven segments: a fast grow, a medium undershoot and a gentle settle.The like-button pop with offsets and per-segment easingSegment 1: grow to 1.3ease-outwaiting400 msSegment 2: undershoot 0.95waitingease-in-outwaiting400 msSegment 3: settle at 1waitingease-out400 msease-outwaitingease-in-out
Uneven segments: a fast grow, a medium undershoot and a gentle settle.

Step-by-step resolution

Shaping a multi-step effectOffsets place the keyframes; keyframe easing shapes each gap.Shaping a multi-step effect1Write the keyframes in order as objects.One object per pose2Add offset values where poses are not evenly spaced.Peak at 18%, undershoot at 55%3Add easing to each keyframe that starts a segment.Fast grow, smooth undershoot, gentle settle4Leave options easing at linear.Segments keep their designed durations5Check the result in the Animations drawer by scrubbing.Visual confirmation of each segment6Under reduced motion, use duration 0 or a simple opacity pulse.
Offsets place the keyframes; keyframe easing shapes each gap.

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 scale and translate.

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.

Where easing goes and what it affectsKeyframe easing shapes a segment; effect easing reshapes the whole iteration.Where easing goes and what it affectsAffectsUse foroptions.easingOverall progress before sampling keyframesTwo-keyframe effectskeyframe.easingInterval from this keyframe to the nextMulti-step effectsBoth non-linearSegments distorted by the outer curveRarely intendedLast keyframe'seasingNothing: no interval followsIgnored
Keyframe easing shapes a segment; effect easing reshapes the whole iteration.

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.