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.
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.
- 1Hold 0–2sopacity 0
- 2Ease in 2–5scubic-bezier
- 3Hold 5–9sopacity 1
- 4Fade out 9–12sto opacity 0
// 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.
| Field | Example | Why it exists |
|---|---|---|
| Time | 1.5 seconds | Places the key on the timeline, in seconds rather than frames |
| Value | 0.25 | The number the property takes at that moment |
| Easing | "easeInOut" | How the value moves toward the next key |
| Spring (optional) | 2.6 Hz, damping 0.3 | Lets 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.
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.
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.
Keep reading
Drag, resize and rotate handles for a canvas editor: building the transform gizmo
Build drag, resize and rotate handles for a canvas editor: hit testing, pointer capture, screen-to-canvas coordinates, aspect locks and rotated bounds.
Compositing video layers with Canvas and WebGL: transforms, blends and effects
Composite video layers in the browser: Canvas 2D versus WebGL, transform matrices, blend modes, shader effects and drawing each frame in the right order.
More in Browser video editor
Web Audio API for video editors: mixing tracks, drawing waveforms and loudness
Use the Web Audio API in a video editor: an audio graph for live mixing, OfflineAudioContext for export, waveform drawing and loudness normalisation.
FFmpeg.wasm vs WebCodecs vs MediaRecorder: choosing a browser video stack
Compare FFmpeg.wasm, WebCodecs and MediaRecorder for browser video: speed, codec support, file size, frame accuracy and which one to choose for an editor.
Text and animated captions in a browser video editor: let CSS do the drawing
Render titles and word-by-word captions in a web video editor: canvas text versus HTML and CSS, font loading, timing words to speech and exporting text.