Skip to content

Lesson 36 - Animation

In this lesson you will learn:

  • How to design an animation API
  • How to implement declarative keyframes and controllers aligned with the Web Animations API
  • How to implement path, stroke, and morphing effects
  • How formats like Lottie fit in

How to design the animation API

Motion is fully compatible with declarative WAAPI-style animation; see Improvements to Web Animations API. It calls the browser’s native element.animate() for GPU acceleration, an independent compositor thread, and work off the main thread. JavaScript fills in what WAAPI does not provide:

  • Spring physics (WAAPI only supports Bézier easing)
  • Independent transform properties (animate x, y, scale separately instead of one combined transform string)
  • transformOrigin for scale and rotation centers
  • Timeline helpers such as sequence() and stagger()
ts
import { animate, stagger } from 'motion';

// Returns a controller you can pause, play, and reverse
const controls = animate(
    '.box',
    { x: [0, 100], opacity: [0, 1] }, // keyframes
    { duration: 0.5, delay: stagger(0.1), easing: 'spring(1, 100, 10, 0)' },
);

// Serializable control calls
controls.pause();
controls.play();
controls.reverse();

Keyframes and options are plain objects and can be JSON-serialized. Runtime state from animate()—bindings to the DOM, current time, velocity, etc.—is not serializable.

Following WAAPI

We can follow the WAAPI polyfill web-animations-js and integrate with our ECS.

Data layer: WAAPI-like keyframes (serializable)

ts
interface Keyframe {
    offset?: number; // 0–1, same role as WAAPI offset
    [property: string]: any; // x, y, scale, fill, strokeWidth...
    easing?: string; // "ease-out", "spring(1, 100)"
}

interface AnimationOptions {
    duration: number; // ms
    delay?: number;
    iterations?: number | 'infinite';
    direction?: 'normal' | 'reverse' | 'alternate';
    fill?: 'forwards' | 'backwards' | 'both'; // How the animation appears when not running (before start, after end).
    easing?: string; // Global easing if a keyframe omits its own
}

Control layer: a WAAPI-like Animation controller. Differences from a full WAAPI polyfill:

  • No CSS string parsing. WAAPI allows { transform: 'translate(100px)' }, which is expensive to parse. Prefer { x: 100 } (Motion-style independent transform props).
  • Built-in ease, ease-in, ease-out, linear, plus Motion-style spring(mass, stiffness, damping).
  • Composite modes. Like WAAPI’s composite: 'add' | 'replace', stack animations on top of base values (e.g. entity position plus an animated offset).
  • Timeline support. Like Motion’s timeline() or WAAPI’s GroupEffect, including stagger across entities.

Controller

ts
const animation = api.animate(
    node1,
    [
        { x: 100, fill: 'green' },
        { x: 200, fill: 'red' },
    ],
    {
        duration: 1000,
        direction: 'alternate',
        iterations: 'infinite',
        easing: 'ease-in-out',
    },
);
animation.pause();
animation.play();
animation.finish();
State:

矩形在 x: 100 ↔ 200 之间往复,填充在 greenred 之间插值;可用按钮控制同一 AnimationController

Interpolation

Scalars like x / y / opacity interpolate trivially. For fill / stroke, parse colors with something like d3-color, then interpolate each rgba channel.

ts
function interpolateValue(from: unknown, to: unknown, t: number) {
    if (isFiniteNumber(from) && isFiniteNumber(to)) {
        return interpolateNumber(from, to, t);
    }
    const fromColor = parseColor(from);
    const toColor = parseColor(to);
    if (fromColor && toColor) {
        return colorToRgbaString({
            r: interpolateNumber(fromColor.r, toColor.r, t),
            g: interpolateNumber(fromColor.g, toColor.g, t),
            b: interpolateNumber(fromColor.b, toColor.b, t),
            a: interpolateNumber(fromColor.a, toColor.a, t),
        });
    }
    return t < 1 ? from : to;
}

Easing

Beyond standard curves, we can support spring:

ts
function evaluateEasing(easing: string, t: number) {
    const p = clamp01(t);
    const bezier = EASING_FUNCTION[easing as keyof typeof EASING_FUNCTION];
    if (bezier) {
        return clamp01(bezier(p));
    }
    if (easing.startsWith('spring(')) {
        return evaluateSpringEasing(p, easing);
    }
    return p;
}
缓动
State:

切换缓动会重新创建动画(新 easing 在构造时生效)。矩形仍在 x: 100 ↔ 200 往复,便于对比不同曲线的加减速感。

Transform origin

State:

transformOrigin: { x: 50, y: 50 }
scale: 0.5 ↔ 1.2rotation: Math.PI / 4 ↔ -Math.PI / 4

Special animation effects

Path animation

Moving graphics along a path is common; CSS does this with Motion Path.

css
#motion-demo {
    animation: move 3000ms infinite alternate ease-in-out;
    offset-path: path('M20,20 C20,100 200,0 200,100');
}
@keyframes move {
    0% {
        offset-distance: 0%;
    }
    100% {
        offset-distance: 100%;
    }
}

Stroke animation

We need the path length:

ts
const length = api.getTotalLength(path);
api.animate(
    path,
    [{ strokeDasharray: [0, length] }, { strokeDasharray: [length, 0] }],
    {
        duration: 3500,
    },
);
State:

Dash offset

draw.io uses animation to show connector direction:

source: https://www.drawio.com/doc/faq/connector-animate

Export your diagram to a SVG file to include the connector animation when you publish it in a web page or on a content platform that supports SVG images.

ts
api.animate(node, [{ strokeDashoffset: -20 }, { strokeDashoffset: 0 }], {
    duration: 500,
    iterations: Infinity,
});
State:

Morphing

Many SVG libraries demonstrate morphing:

Some libraries require matching segment structure before and after the morph, or interpolation fails.

Following Kute.js’s CubicMorph: convert path segments to cubic Béziers, use easy subdivision to normalize both paths to the same segment count, then interpolate control points per segment.

ts
function mergePaths(
    left: { absolutePath: AbsoluteArray; curve: CurveArray | null },
    right: { absolutePath: AbsoluteArray; curve: CurveArray | null },
): [CurveArray, CurveArray, (b: CurveArray) => CurveArray] {
    let curve1 = left.curve;
    let curve2 = right.curve;
    if (!curve1 || curve1.length === 0) {
        // convert to curves to do morphing & picking later
        // @see http://thednp.github.io/kute.js/svgCubicMorph.html
        curve1 = path2Curve(left.absolutePath, false) as CurveArray;
        left.curve = curve1;
    }
    if (!curve2 || curve2.length === 0) {
        curve2 = path2Curve(right.absolutePath, false) as CurveArray;
        right.curve = curve2;
    }

    let curves = [curve1, curve2];
    if (curve1.length !== curve2.length) {
        curves = equalizeSegments(curve1, curve2);
    }

    const curve0 =
        getDrawDirection(curves[0]) !== getDrawDirection(curves[1])
            ? reverseCurve(curves[0])
            : (clonePath(curves[0]) as CurveArray);

    return [
        curve0,
        getRotatedCurve(curves[1], curve0) as CurveArray,
        (pathArray: CurveArray) => {
            // need converting to path string?
            return pathArray;
        },
    ];
}
State:

Lottie

Usage

We implemented a plugin that converts Lottie JSON into graphics and keyframes. Highlights:

  • Supports the following elements from Shape layers:
  • In Lottie, anchorX / anchorY define the scale and rotation center relative to the top-left of the shape’s bounding box—take care when mapping to transformOrigin
  • Merge multiple animation tracks into one keyframe set and fill in missing properties
ts
import { loadAnimation } from '@infinite-canvas-tutorial/lottie';

fetch('/bouncy_ball.json')
    .then((res) => res.json())
    .then((data) => {
        const animation = loadAnimation(data, {
            loop: true,
            autoplay: true,
        });

        api.runAtNextTick(() => {
            animation.render(api);
            animation.play();
        });
    });

Below is the official sample running in our setup: Bouncy Ball

State:

Bézier curves in Lottie

Beziers in Lottie

  • v is an array of vertices.
  • i is an array of “in” tangent points, relative to v.
  • o is an array of “out” tangent points, relative to v.
  • c is a boolean determining whether the poly-Bézier is closed. If it is, there is an extra Bézier segment between the last point in v and the first.

Expressions

Expressions

json
{
    "ty": "sh",
    "ks": {
        "a": 0,
        "k": {
            "i": [],
            "o": [],
            "v": []
        },
        "x": "var group = thisLayer.content(\"Quadratic Points\");\nvar num_points = 3;\nvar points = [];\nvar ip = [];\nvar op = [];\nfor ( var i = 0; i < num_points; i++ )\n{\n    var pos = group.content(\"p\" + i).position;\n    points.push(pos);\n    ip.push(pos);\n    op.push(pos);\n}\nvar $bm_rt = {\n    v: points,\n    i: ip,\n    o: op\n};\n"
    }
}

Text layer

Clipping mask

clipping-masks

Layer effects

Layer Effects

Rive

Manim

https://github.com/3b1b/manim

Animation editors

Further reading

Released under the MIT License.