Skip to main content

cubic-bezier(.4,0,.2,1)

Keyframe animation in a web editor: interpolation, easing curves and motion tracking

Implement keyframe animation for a web editor: storing keyframes, interpolating values, cubic Bézier easing, and pinning graphics to tracked motion.

15 min readEngineering

Part 13 of 15Build a browser video editor

cubic-bezier(.4,0,.2,1)azelify
Engineering

Keyframe animation in a web editor: interpolation, easing curves and motion tracking

Key takeaways

  • A keyframe is a value pinned to a time; animation is finding the two keyframes around now and blending between them.
  • Easing changes how fast the blend progresses, and a cubic Bézier curve with two control points covers most motion people want.
  • Motion tracking turns a moving object into generated keyframes, so a graphic can follow it through the shot.

Keyframe animation in JavaScript starts with one idea: store a value at a few chosen moments, then compute every value in between. A logo that slides across a screen needs only two positions and a rule for moving between them. A caption that grows and settles needs a few more. This guide builds that idea from nothing, so a reader with no animation background can follow each step and run the code.

Keyframe animation is the core of motion in any video editor. Titles fly in, layers fade, and a camera appears to drift over a still photograph, all because a small table of numbers is evaluated at each frame. The Azelify editor is our worked example. It is a browser-native video editor, and its handling of keyframes shows several trade-offs that most tutorials skip, including how easing curves are chosen, how springs settle and how motion tracking can supply keyframes automatically.

We move from the bottom up. We define a keyframe, learn how to find the two keyframes around a moment, blend between their values, reshape that blend with easing functions, look at holds and springs, combine several properties, and finish with motion tracking, which measures a moving object and turns its path into data.

What is keyframe animation in JavaScript?#

An animation is a value that changes with time. A keyframe is a value pinned to a particular moment, for example "the opacity is 0 at 2 seconds and 1 at 3 seconds". The animator decides the important moments, and the computer fills in the frames between them. The older name for this is inbetweening, the job of drawing the frames between two key poses. In traditional studios, a lead artist drew the keys and assistants drew the in-betweens. Software does the same job with arithmetic.[1]

The browser gives us a clock for this. A frame is a single picture, and at 30 frames per second a frame lasts about 33 milliseconds. Each frame, the program asks one question: given the current time in seconds, what is each property's value? The answer comes from the keyframes, and the screen shows the result. A video editor repeats that question for every frame of an export, so the lookup has to be fast and must always give the same answer for the same time.

The rest of the article is about answering that question well. Keep one picture in mind as you read. A timeline is a horizontal line of seconds. Keyframes are dots on that line, each carrying a value. Between two dots, the program draws a curve, and the curve decides how the value moves from one dot to the next.

Fig. 01
Hold0–2s
Ease in2–5s
Hold5–9s
Fade out9–12s
0s5s10s
2s · Key A5s · Key B9s · Key C
Four keyframes divide a twelve-second layer into segments with their own rulesIllustrative timeline in seconds. A hold from zero to two seconds keeps opacity at zero. An eased move runs from two to five seconds. A hold from five to nine seconds keeps the layer fully visible. A fade out runs from nine to twelve seconds. Markers show the keyframes at two, five and nine seconds.1234
  1. 1Hold 0–2sopacity 0
  2. 2Ease in 2–5scubic-bezier
  3. 3Hold 5–9sopacity 1
  4. 4Fade out 9–12sto opacity 0
Four keyframes divide a twelve-second layer into segments with their own rules
Illustrative, not data[wiki-inbetween]
// A keyframed property is a list of keys sorted by time.
// Each key says what the value is at a moment, and how to move toward the next key.
const opacity = [
  { t: 0.0, value: 0.0 },              // invisible at the start
  { t: 1.0, value: 1.0, ease: "easeOut" }, // fully visible at one second, easing out
  { t: 3.0, value: 1.0, ease: "hold" },    // stay visible until three seconds
  { t: 4.0, value: 0.0 },              // gone at four seconds
];

What is a keyframe, and how is it stored?#

A keyframe is a small record. It needs a time, a value and, for every key except the last, a rule for the segment that follows it. The table lists the fields that a practical editor needs. Each field exists because a real edit depends on it.

FieldExampleWhy it exists
Time1.5 secondsPlaces the key on the timeline, in seconds rather than frames
Value0.25The number the property takes at that moment
Easing"easeInOut"How the value moves toward the next key
Spring (optional)2.6 Hz, damping 0.3Lets the segment settle with a natural wobble

Storing time in seconds, rather than in frame numbers, keeps keys valid when the frame rate changes. A key at 1.5 seconds still means the same moment in a 24-frame export and a 60-frame preview. Keep the list sorted by time. Sorting makes the lookup fast and makes an out-of-order edit easy to spot.

How do you find the two keyframes around a time?#

Given a time t, the lookup has four cases. If t is before the first key, the value is the first key's value. If t is after the last key, the value is the last key's value. In between, you need the pair of keys that surround t, which means the last key at or before t and the first key after it. The code below walks the sorted list until it finds that pair.

// Return the keyed value of one property at time t, in seconds.
// keys must be sorted by t, with at least one key.
function valueAt(keys, t) {
  if (t <= keys[0].t) return keys[0].value;                 // before the first key: hold it
  const last = keys[keys.length - 1];
  if (t >= last.t) return last.value;                        // after the last key: hold it
  let i = 0;
  while (keys[i + 1].t < t) i++;                             // advance to the segment holding t
  const a = keys[i];
  const b = keys[i + 1];
  const u = (t - a.t) / (b.t - a.t);                         // progress through the segment, 0 to 1
  const y = easingFor(a.ease)(u);                            // reshape the progress
  return a.value + (b.value - a.value) * y;                  // blend between the two values
}

A linear scan is fine for a handful of keys. A timeline with thousands of keys should use binary search to find the segment. Either way, the lookup is pure: nothing is stored between frames, so the same time always gives the same value. That purity is what lets an editor scrub backwards, render an export out of order and compare two runs.

The function calls easingFor, which maps a name to a function. Keep that mapping in one place, so a name used in a file always means the same curve.

// Map easing names to functions that take progress u (0..1) and return shaped progress.
const easings = {
  linear: (u) => u,                                    // constant speed
  hold: (u) => (u < 1 ? 0 : 1),                        // stay at the old value until the next key
  easeOut: cubicBezierEase(0, 0, 0.58, 1),             // fast start, gentle landing
};
const easingFor = (name) => easings[name] ?? easings.linear;

What is linear interpolation, or lerp?#

Linear interpolation (opens in a new tab), usually shortened to lerp, is the blend at the heart of the lookup. It takes two values a and b and a fraction t between 0 and 1, and returns the point that far along the straight line between them. The formula is a + (b − a) × t. When t is 0 you get a, when t is 1 you get b, and when t is one half you get the midpoint.[2]

// Linear interpolation: move the fraction t of the way from a to b.
const lerp = (a, b, t) => a + (b - a) * t;

lerp(0, 100, 0.25); // 25: a quarter of the way from 0 to 100
lerp(10, 20, 0.5);  // 15: the midpoint

Lerp is fast and predictable, but it has a visible flaw. Its speed is constant, so a movement starts and stops abruptly. A ball moving with lerp appears to hit a wall at each key. People expect motion to accelerate and decelerate, and that expectation is what easing supplies. Lerp itself is not wrong. It is just the shape you reshape.

How do easing functions and cubic Bézier curves work?#

An easing function takes progress u from 0 to 1 and returns a shaped progress from 0 to 1. The shape changes how the value moves while the timing stays the same. A linear easing returns u unchanged. An ease-out returns a curve that rises fast and flattens at the end. The CSS Easing Functions specification (opens in a new tab) defines this idea for transitions and animations, and MDN's guide lists the keyword easings that browsers provide.[3][4]

The most common family is the cubic Bézier curve. A Bézier curve is a smooth path drawn between control points, and a cubic one has two control points in addition to its two endpoints.[5] CSS uses a special version whose endpoints are fixed at (0, 0) and (1, 1). You supply the two control points, and the horizontal coordinates are restricted to the range 0 to 1 so that time always moves forward. The result is written cubic-bezier(x1, y1, x2, y2). The vertical coordinates may leave the 0 to 1 band, which is how a curve can overshoot.

To use such a curve, the program must answer a slightly awkward question. We know a time fraction u, and we need the height of the curve at that time. The curve is defined by a parameter s, not by time, so the program must find the s whose horizontal position equals u, then read the vertical position at that s. The code below does this with bisection, which halves a search interval until it is small enough.

// Evaluate a CSS-style cubic-bezier easing at progress u in [0, 1].
// The curve runs from (0,0) to (1,1) with control points (x1,y1) and (x2,y2).
function cubicBezierEase(x1, y1, x2, y2) {
  // One coordinate of a cubic Bezier whose endpoints are 0 and 1, at parameter s.
  const bezier = (p1, p2, s) => {
    const inv = 1 - s;
    return 3 * inv * inv * s * p1 + 3 * inv * s * s * p2 + s * s * s;
  };
  return (u) => {
    if (u <= 0) return 0;
    if (u >= 1) return 1;
    let lo = 0;
    let hi = 1;
    for (let i = 0; i < 40; i++) {                  // bisection on the parameter s
      const mid = (lo + hi) / 2;
      if (bezier(x1, x2, mid) < u) lo = mid;        // the curve's x is still too small
      else hi = mid;
    }
    return bezier(y1, y2, (lo + hi) / 2);           // the height at the matching s
  };
}

The keyword easings in CSS are shorthand for fixed curves. The standard "ease" is cubic-bezier(0.25, 0.1, 0.25, 1), and "ease-out" is cubic-bezier(0, 0, 0.58, 1), which is the one the example above uses. A second control-point height above 1 produces an overshoot, where the value passes its target and returns. The next section explains why a designer might want that effect.

Choose the easing by the feeling you want. Ease-out suits an element arriving on screen, because it begins quickly and settles. Ease-in suits an element leaving, because it accelerates away. Ease-in-out suits a movement between two rest positions. Linear suits a continuous pan or a constant rotation, where a pause would look wrong.

Fig. 02
Pick an easing from the feeling you want the motion to haveDecision tree. If the value must move smoothly, ask whether it should overshoot and settle. A yes gives a spring, and a no gives a named cubic Bezier curve. If the value need not move smoothly, use a hold or linear steps.yesyesnono
Should the value move smoothly?
Should it overshoot and settle?
Spring easing
Named cubic-bezier curve
Hold or linear steps
  1. Should the value move smoothly?
    1. yes → Should it overshoot and settle?
      1. yes → Spring easing
      2. no → Named cubic-bezier curve
    2. no → Hold or linear steps
Pick an easing from the feeling you want the motion to have
Illustrative, not data

How do hold keyframes and spring easing behave?#

A hold is a segment whose value does not move at all until the next key. It is the video equivalent of a freeze, and it is useful for a title that must stay still while it is read, or for a value that changes on a beat rather than gradually. The hold easing in the earlier code returns zero until the progress reaches 1, which makes the value jump at the next key.

A spring is a different kind of curve. A physical spring released from a stretched position overshoots its rest point, returns, overshoots again more weakly, and finally comes to rest. The motion is described by a damped harmonic oscillator, a mass on a spring with friction, which is a standard model in physics.[6][7] Two numbers control it. The frequency sets how fast it wobbles, and the damping ratio sets how quickly the wobble dies away. A damping ratio below 1 is underdamped and overshoots, a ratio of exactly 1 is critical and returns without overshoot, and a ratio above 1 creeps back slowly.

// Step response of a damped spring released from 0 toward a target of 1, at time t seconds.
// frequencyHz sets the wobble speed. dampingRatio below 1 makes the value overshoot.
function springStep(t, frequencyHz, dampingRatio) {
  const w = 2 * Math.PI * frequencyHz;             // angular speed, radians per second
  const z = dampingRatio;
  const wd = w * Math.sqrt(1 - z * z);             // wobble frequency, for z below 1
  const envelope = Math.exp(-z * w * t);           // the wobble shrinks as time passes
  return 1 - envelope * (Math.cos(wd * t) + (z * w / wd) * Math.sin(wd * t));
}
// With dampingRatio 0.3 the peak overshoot is about 37 percent of the distance.

The Azelify editor uses a spring as one of its easings. Its default is 2.6 hertz with a damping ratio of 0.30, which gives a visible pop that settles within a fraction of a second. A spring segment runs over its own duration, and near its end it blends onto the next key, so the motion lands without a jump. We return to this in the editor section.

How do you run keyframe animation in JavaScript for several properties?#

A real layer has several properties at once: position, scale, rotation and opacity. Each property has its own keys, so the program evaluates each one separately and then combines the results into one transform. A layer might fade in with opacity while it slides with position. The two timings are independent, and the composite is simply the set of answers at time t.

Animation can also come from outside the keys. Music can drive a value through a driver, which adds a motion to the keyed value. The rule is simple: the value at time t is the keyed value plus the sum of every driver's contribution at t. A beat-driven pulse adds a bump on each beat, and it stacks with a slow keyed drift.

// A property's value is its keyed value plus the sum of all of its drivers at time t.
// A driver is any function of time, such as a pulse that peaks on each beat.
function propertyValue(keys, drivers, t) {
  return valueAt(keys, t) + drivers.reduce((sum, driver) => sum + driver(t), 0);
}

// A simple driver: a bump that decays after every beat time in the list.
const beatPulse = (beats) => (t) => {
  let level = 0;
  for (const b of beats) {
    if (t >= b && t - b < 0.25) level = Math.max(level, 1 - (t - b) / 0.25);
  }
  return level;
};

Keeping drivers separate from keys has a practical benefit. The keys stay as the author wrote them, and the driver data can be replaced, for example when a new song is chosen, without rewriting the keys. The whole value is recomputed from data on each frame, so the change appears at once.

How does motion tracking create keyframes?#

Keyframes are often written by hand, but many motions are already in the footage. Motion tracking measures how a region of the picture moves from one frame to the next, and the result can be used directly or converted into keyframes. The field is called match moving in visual effects, and the term covers how a graphic is locked to a moving object or camera.[8]

The simplest tracker is template matching. You choose a small patch of the picture, the template, and search for it in the next frame. At each candidate position you compare the template with the image under it, and keep the position with the best score. The score most often used is normalised cross-correlation, abbreviated NCC. It subtracts each patch's average brightness, so that a change in lighting does not hide the match, and it divides by the spread of values, so the score always falls between minus 1 and 1.[9]

// Normalised cross-correlation between a template and an image patch.
// Both are flat arrays of grey values of the same length. The score runs from -1 to 1.
function ncc(template, patch) {
  const n = template.length;
  let meanT = 0;
  let meanP = 0;
  for (let i = 0; i < n; i++) {
    meanT += template[i];
    meanP += patch[i];
  }
  meanT /= n;
  meanP /= n;
  let num = 0;
  let denT = 0;
  let denP = 0;
  for (let i = 0; i < n; i++) {
    const a = template[i] - meanT;   // remove the average brightness of the template
    const b = patch[i] - meanP;      // remove the average brightness of the patch
    num += a * b;
    denT += a * a;
    denP += b * b;
  }
  const den = Math.sqrt(denT * denP);
  return den === 0 ? 0 : num / den;  // 1 is a perfect match; a flat patch scores 0
}

Template matching assumes the patch keeps its shape, so it struggles with rotation, scale and perspective. Two other ideas go further. Optical flow estimates a motion vector at many points by assuming that a point's brightness stays constant between frames. That assumption is often broken by lighting changes, and a single point cannot always determine motion in both directions, a limit called the aperture problem.[10] A planar tracker follows a flat surface, such as a sign or a screen. A flat surface seen by a camera moves by a homography, a mapping defined by a 3-by-3 matrix that has eight free numbers once its overall scale is ignored.

A tracker produces one position per frame. Turning that path into keyframes is a fitting step. One design keeps the raw path and smooths it when it is used. Another samples the path at a few times and fits keys to it. Each choice trades storage and editability against fidelity.

Step-by-step: from keyframes to a frame#

The complete path, from stored keys to a pixel on screen, has five steps. Each step can be tested on its own, which is useful when a motion looks wrong and you need to know which stage is at fault. The figure shows the lookup as a sequence.

Fig. 03
Each frame turns a time into a value through five stepsIllustrative pipeline from left to right. The keys for one property are read, the two keys around the current time are found, the progress through that segment is computed and shaped by easing, and the two values are blended with lerp. Any drivers are then added, and the result builds the transform that is drawn.
Current time tseconds on the timeline
Find key pairkeys k0 and k1
Normalise to 0..1u = (t-t0)/(t1-t0)
Apply easingy = ease(u)
Lerp valuea + (b - a) * y
  1. 01 → Current time tseconds on the timeline
  2. 02 → Find key pairkeys k0 and k1
  3. 03 → Normalise to 0..1u = (t-t0)/(t1-t0)
  4. 04 → Apply easingy = ease(u)
  5. 05 → Lerp valuea + (b - a) * y
Each frame turns a time into a value through five steps
Illustrative, not data[wiki-lerp]

Step 1: Read the keys for each property#

Load the sorted key list for the property. Check that the times ascend and that every easing name is one the editor knows. A bad name is cheapest to catch when the file is loaded.

Step 2: Find the segment for time t#

Locate the two keys around the current time, using the lookup function above or a binary search. Handle the cases before the first key and after the last key separately.

Step 3: Compute the progress and shape it#

Compute u as the fraction of the segment that has elapsed, then apply the segment's easing to get the shaped progress.

Step 4: Blend and add drivers#

Blend the two values with lerp, then add the contribution of each driver at time t. Do this for every property in the layer.

Step 5: Build the transform and draw#

Combine the property values into a transform, such as translation, scale, rotation and opacity, and draw the layer with that transform. Because every step is a pure function of t, the same frame is always identical.

How the Azelify editor handles keyframes#

The Azelify editor evaluates keyframe curves through one engine that serves any property that can be animated. Playing a speed ramp is also a keyframe, not a special case. A named speed preset expands into ordinary keys, so once it is applied it behaves exactly like a ramp that a person drew by hand. We chose this because a special code path for speed would need its own tests and its own bugs, and the keyframe path already has both.

The easing names come from one shared list. The menu that offers them is built from that list, and the validator refuses any name outside it. The list contains five basic curves, a family of ease-in, ease-out and ease-in-out variants for each named family, and the spring. Because every interface reads one list, a name cannot mean one thing in the menu and another in the render. That single source of truth prevents a common class of bug in animation tools.

A spring is the one easing that carries its own parameters. A spring segment follows its own frequency and damping values, with bounds that keep the motion physical, and it blends onto the next key over the last tenth of its duration so that it lands without a jump. The default, 2.6 hertz with a damping ratio of 0.30, was measured from a real pop, so it is a reference motion rather than a guess. The bounds keep the frequency and damping inside a range where the motion reads as a spring, rather than a flicker or an almost endless wobble.

Editing the curves brings its own rule. When you add a key in the middle of an eased movement, the picture stays exactly as it was at that moment. The two halves of the old segment then ease into and out of the new key, so the motion keeps its shape. Without this rule a new key would cause a visible jump, which is the kind of change an editor should never make silently.

Music drivers follow the rule shown earlier. The value at time t is the keyed value plus the sum of the drivers. The driver data is stored in the project and evaluated when the frame is drawn, so replacing a song re-drives every property that uses it. A trade-off follows from this. Evaluating drivers each frame costs a little time, but it means a change to the song is instant rather than a re-bake of every frame.

Motion tracking in the editor follows the same principle of keeping raw data. Stabilisation stores the measured camera path and smooths it at render time, so moving the smoothing slider does not require measuring the footage again. Preview and export read the same stored path and therefore cannot disagree. Point and planar tracking follow a surface or a point across frames, and their positions are kept in normalised coordinates from zero to one. Because of that, a proxy analysis at low resolution applies correctly at full resolution. An analysis that cannot run, because no frame source is available, fails loudly rather than producing a plausible but made-up path. We think a visible error is better than a convincing guess.

Frequently asked questions#

How do I make a smooth animation in JavaScript without a library?#

Store a sorted list of keys, find the two keys around the current time, compute the progress between them, shape it with an easing function, and blend the values with lerp. Run that lookup on every frame. For a visible effect in the browser, the same calculation can drive a canvas (opens in a new tab) or a CSS transform.

What is the difference between lerp and easing?#

Lerp blends two values at a constant rate, so the speed never changes. Easing reshapes the progress before the blend, so the speed can rise and fall. Easing is applied to the fraction, and lerp then does the blending.

What does cubic-bezier mean in CSS?#

It names a curve by four numbers, the two control points that shape it. The curve always starts at (0, 0) and ends at (1, 1). The first and third numbers are restricted to the range from 0 to 1, and the second and fourth may go outside it to produce an overshoot.

How many keyframes does an animation need?#

Two keyframes give a simple movement, and three or four are enough for most titles. The number depends on how many changes of direction you need, not on the length of the clip. Fewer, well-placed keys are easier to edit than many small ones.

Can motion tracking add keyframes automatically?#

It can. A tracker measures a path frame by frame, and that path can be sampled and fitted to keys, or used directly as data. The result is only as good as the tracked feature, so a clean, textured patch of the picture tracks more reliably than a blank wall.

Why does my spring overshoot the target?#

A damping ratio below 1 makes the spring overshoot, which is intended for a playful pop. If you want the motion to settle without passing its target, raise the damping ratio to 1 or above. Lower frequencies make the same motion slower.

Where to go next#

Keyframes move values, and the transforms they drive are placed by the compositor. Read video compositing with canvas and WebGL to see how a moved layer is blended and masked in the frame. For the handles that let a person drag those values directly, drag, resize and rotate handles for a canvas editor shows how pointer positions become transform values.

Sources

  1. Wikipedia, “Inbetweening,” en.wikipedia.org (opens in a new tab) (accessed 2026-10-13). ↩

  2. Wikipedia, “Linear interpolation,” en.wikipedia.org (opens in a new tab) (accessed 2026-10-13). ↩

  3. W3C, “CSS Easing Functions Level 1,” www.w3.org (opens in a new tab) (accessed 2026-10-13). ↩

  4. MDN Web Docs, “CSS easing functions,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  5. Wikipedia, “Bézier curve,” en.wikipedia.org (opens in a new tab) (accessed 2026-10-13). ↩

  6. Wikipedia, “Harmonic oscillator,” en.wikipedia.org (opens in a new tab) (accessed 2026-10-13). ↩

  7. Wikipedia, “Damping,” en.wikipedia.org (opens in a new tab) (accessed 2026-10-13). ↩

  8. Wikipedia, “Match moving,” en.wikipedia.org (opens in a new tab) (accessed 2026-10-13). ↩

  9. arXiv, “Template Matching Advances and Applications in Image Analysis,” arxiv.org (opens in a new tab) (accessed 2026-10-13). ↩

  10. Wikipedia, “Optical flow,” en.wikipedia.org (opens in a new tab) (accessed 2026-10-13). ↩

Build a browser video editor

In this series

  1. 01How to build a video editor in the browser: the complete architecture
  2. 02Designing the project file for a web video editor: schema, versions and autosave
  3. 03Undo and redo in a web editor: the command pattern, explained from zero
  4. 04How a video editor timeline works: tracks, clips, trims and overlaps
  5. 05Importing video files in the browser: probing, codecs, proxies and storage
  6. 06WebCodecs tutorial: decoding video frames in the browser without a video tag
  7. 07Frame-accurate playback in JavaScript: clocks, requestAnimationFrame and A/V sync
  8. 08Compositing video layers with Canvas and WebGL: transforms, blends and effects
  9. 09Text and animated captions in a browser video editor: let CSS do the drawing
  10. 10Drag, resize and rotate handles for a canvas editor: building the transform gizmo
  11. 11Web Audio API for video editors: mixing tracks, drawing waveforms and loudness
  12. 12Exporting MP4 from the browser: WebCodecs encoding, muxing and server fallback
  13. 13Keyframe animation in a web editor: interpolation, easing curves and motion tracking
  14. 14How to test a video renderer: golden images, oracles and property-based tests
  15. 15FFmpeg.wasm vs WebCodecs vs MediaRecorder: choosing a browser video stack

Animate a layer with keyframes

Set a start and an end, pick an easing curve and press play.

Open the studio

Keep reading

More in Browser video editor

All guides