Search Docs
Loading...
Skip to content

Keyframes

Animate single block properties over time with keyframes, such as an opacity fade, a color change or a growing corner radius.

10 mins
estimated time
Download
StackBlitz
GitHub

Preset in, out and loop animations cover common motion. Keyframes give you control over each value, each time and each curve. A keyframe track belongs to one property of one block and renders at the current playback time and in frame exports. For preset animations, see Create Animations.

This guide covers how to check which properties accept keyframes, how time and values work, easing, absolute and relative tracks, anchor, skew and auto-orient, how a block follows another block, reading tracks back, removing them, and the errors to expect.

Creating the Animated Block#

We create a rectangle with a color fill and a rect shape, and give it a duration of 5 seconds. Keyframes belong to the block that owns the property. The graphic owns opacity and position/x, the fill owns fill/color/value and the shape owns the corner radii.

// A 5 second rectangle with a color fill and a rect shape
const block = engine.block.create('graphic');
engine.block.setShape(block, engine.block.createShape('rect'));
engine.block.setFill(block, engine.block.createFill('color'));
engine.block.setWidth(block, 600);
engine.block.setHeight(block, 400);
engine.block.setPositionX(block, 360);
engine.block.setPositionY(block, 340);
engine.block.appendChild(page, block);
engine.block.setDuration(block, 5);
// The fill and the shape own their properties and their keyframes
const fill = engine.block.getFill(block);
const shape = engine.block.getShape(block);

Checking Animatable Properties#

isPropertyAnimatable() tells you whether a property of a block accepts keyframes. It returns false for a property that the block does not have, for example a star property on a rect shape, and for a property that cannot be animated, such as visible.

console.log(engine.block.isPropertyAnimatable(block, 'opacity')); // true
console.log(engine.block.isPropertyAnimatable(fill, 'fill/color/value')); // true
console.log(engine.block.isPropertyAnimatable(block, 'visible')); // false

Pass the block that owns the property. fill/color/value is animatable on the fill block, not on the graphic.

Setting Keyframes#

setKeyframes() replaces the whole track of a property. Each keyframe has a time from 0 to 1, normalized over the block’s duration. A fill, shape or effect uses the duration of the block it belongs to. Here the fade ends at 0.4, which is 2 seconds into the 5 second block.

// Fade in during the first 40% of the block's duration (2 seconds)
engine.block.setKeyframes(block, 'opacity', [
{
time: 0,
value: { type: 'Float', value: 0 },
easing: { type: 'Preset', preset: 'EaseOut' }
},
{ time: 0.4, value: { type: 'Float', value: 1 } }
]);

The engine sorts the keyframes by time. Before the first keyframe the property keeps the first value, and after the last keyframe it keeps the last value. When you change the block’s duration, the animation stretches with it.

Keyframe Values#

The type of each value must match the kind of the property, and all keyframes of one track use the same kind.

Type Value Example property
'Float' A number opacity, rotation, shape/rect/cornerRadiusTL
'Color' An RGBA, CMYK or spot color fill/color/value
'Vec2' { x, y } position, shape/position
'List' An array of numbers stroke/dashArray, fill/gradient/colors
'Path' { vertices, closed }, a bezier path shape/vector_path/path
'Discrete' A boolean, an integer or a string stroke/enabled, blend/mode, text/text

Color keyframes use the same color shape as setColor(). All color keyframes of a track must use one color space.

// Orange, then a hold on purple, then a fade to teal
engine.block.setKeyframes(fill, 'fill/color/value', [
{
time: 0,
value: { type: 'Color', value: { r: 1, g: 0.5, b: 0, a: 1 } }
},
{
time: 0.5,
value: { type: 'Color', value: { r: 0.5, g: 0.2, b: 0.9, a: 1 } },
easing: { type: 'Hold' }
},
{
time: 0.75,
value: { type: 'Color', value: { r: 0.4, g: 0.8, b: 0.8, a: 1 } }
},
{
time: 1,
value: { type: 'Color', value: { r: 0, g: 0.6, b: 0.6, a: 1 } }
}
]);

A 'Discrete' value never interpolates. It changes at the time of the next keyframe. An integer value selects an enum option by its index, and an index that names no option throws BLOCK.ENUM_VALUE_INVALID.

// Switch the stroke on halfway; discrete values never interpolate
engine.block.setStrokeWidth(block, 12);
engine.block.setStrokeColor(block, { r: 0, g: 0, b: 0, a: 1 });
engine.block.setKeyframes(block, 'stroke/enabled', [
{ time: 0, value: { type: 'Discrete', value: false } },
{ time: 0.5, value: { type: 'Discrete', value: true } }
]);

Easing Between Keyframes#

The easing of a keyframe shapes the curve from that keyframe to the next one. Without easing, a keyframe uses the 'Linear' preset.

  • { type: 'Preset', preset: 'EaseOut' } uses one of the animation easing presets, the same names as the animationEasing property of preset animations.
  • { type: 'Bezier', out, in } is a cubic bezier, as in CSS cubic-bezier(out.x, out.y, in.x, in.y). Both x values must be from 0 to 1.
  • { type: 'Hold' } keeps the value until the next keyframe. In the color track above, the purple keyframe holds from 50% to 75% and then jumps.

We grow all four corner radii with a bezier curve that starts slow and ends slow.

// Grow every corner radius with a CSS-style cubic bezier
const corners = ['TL', 'TR', 'BL', 'BR'];
for (const corner of corners) {
engine.block.setKeyframes(shape, `shape/rect/cornerRadius${corner}`, [
{
time: 0,
value: { type: 'Float', value: 0 },
easing: {
type: 'Bezier',
out: { x: 0.7, y: 0 },
in: { x: 0.3, y: 1 }
}
},
{ time: 1, value: { type: 'Float', value: 200 } }
]);
}

Absolute and Relative Tracks#

A track is 'Absolute' by default, so its value replaces the static value of the property. Pass 'Relative' as the fourth argument to add the track value to the static value. Relative tracks work for number and vec2 properties only.

// Slide 600 units to the right of the block's static x position
engine.block.setKeyframes(
block,
'position/x',
[
{ time: 0, value: { type: 'Float', value: 0 } },
{ time: 1, value: { type: 'Float', value: 600 } }
],
'Relative'
);

This relative position/x track moves the block 600 units to the right of wherever it is placed. When you move the block, the whole motion moves with it.

Motion Paths and Scale#

position of a block is a vec2 property, so one track moves both axes. The dot follows a curved path: its spatialOut and spatialIn tangents bend the path between two keyframes.

// A dot follows a curved path. Vec2 keyframes animate `position/x` and
// `position/y` together, and the tangents bend the path upward.
const dot = engine.block.create('graphic');
engine.block.setShape(dot, engine.block.createShape('ellipse'));
const dotFill = engine.block.createFill('color');
engine.block.setColor(dotFill, 'fill/color/value', {
r: 0.9,
g: 0.2,
b: 0.4,
a: 1
});
engine.block.setFill(dot, dotFill);
engine.block.setWidth(dot, 120);
engine.block.setHeight(dot, 120);
engine.block.appendChild(page, dot);
engine.block.setDuration(dot, 5);
// The static position is the start of the path.
engine.block.setVec2(dot, 'position', { x: 200, y: 900 });
engine.block.setKeyframes(dot, 'position', [
{
time: 0,
value: { type: 'Vec2', value: { x: 200, y: 900 } },
spatialOut: { x: 300, y: -600 },
spatialIn: { x: -300, y: -600 }
},
{ time: 1, value: { type: 'Vec2', value: { x: 1600, y: 900 } } }
]);

For which keyframe holds which tangent, straight paths, single-axis tracks and the errors to expect, see Motion Paths.

Rotation and scale tracks pivot on the block’s anchor, transform/anchor/x and transform/anchor/y, in design units from the top-left corner of the frame. A block without an anchor turns about its center, as setRotation() turns it, and scales about its top-left corner. Set the anchor, even to (0, 0), to make it the pivot of the rotation too. hasTransformAnchor() tells whether a block has an anchor.

transform/scale/x and transform/scale/y scale a graphic, text or caption block about its anchor without changing its frame. A negative value mirrors the block, so a track that shows another sign than the static scale also needs the layer/flip scope.

// Pulse the dot to 1.5 times its size and back. Its frame does not change.
for (const axis of ['x', 'y']) {
engine.block.setKeyframes(dot, `transform/scale/${axis}`, [
{ time: 0, value: { type: 'Float', value: 1 } },
{ time: 0.5, value: { type: 'Float', value: 1.5 } },
{ time: 1, value: { type: 'Float', value: 1 } }
]);
}

Scale tracks take 'Float' keyframes. playback/timeRemap of a composition group also takes them; see Time Remap.

Anchor and Skew#

transform/anchor/x and transform/anchor/y set the point that rotation, scale and skew pivot on, and transform/skew shears the block. The bar below has its anchor at its center and leans by 20 degrees, so its rotation track turns it in place.

// A bar that leans by 20 degrees and turns half a turn about its centre.
// The anchor is in design units from the top-left corner of the frame.
const bar = engine.block.create('graphic');
engine.block.setShape(bar, engine.block.createShape('rect'));
engine.block.setFill(bar, engine.block.createFill('color'));
engine.block.setWidth(bar, 320);
engine.block.setHeight(bar, 80);
engine.block.setPositionX(bar, 120);
engine.block.setPositionY(bar, 120);
engine.block.appendChild(page, bar);
engine.block.setDuration(bar, 5);
engine.block.setFloat(bar, 'transform/anchor/x', 160);
engine.block.setFloat(bar, 'transform/anchor/y', 40);
engine.block.setFloat(bar, 'transform/skew', 20);
engine.block.setKeyframes(bar, 'rotation', [
{ time: 0, value: { type: 'Float', value: 0 } },
{ time: 1, value: { type: 'Float', value: Math.PI } }
]);

hasTransformAnchor() tells whether a block has an anchor. Setting transform/anchor/x or transform/anchor/y, even to 0, or giving one of them keyframes, gives a block an anchor. A rotation track turns a graphic or text with an anchor about the anchor, and one without about its frame center.

// The bar has an anchor, so its rotation track turns it about the anchor.
// A graphic or text without an anchor turns about its frame centre.
console.log(engine.block.hasTransformAnchor(bar)); // true

Auto-Orient#

When transform/autoOrient is on, a block turns to follow the direction of its position keyframes.

// A bar on a wavy path turns to follow the direction of its motion
const arrow = engine.block.create('graphic');
engine.block.setShape(arrow, engine.block.createShape('rect'));
const arrowFill = engine.block.createFill('color');
engine.block.setColor(arrowFill, 'fill/color/value', {
r: 0.1,
g: 0.6,
b: 0.3,
a: 1
});
engine.block.setFill(arrow, arrowFill);
engine.block.setWidth(arrow, 160);
engine.block.setHeight(arrow, 40);
engine.block.appendChild(page, arrow);
engine.block.setDuration(arrow, 5);
engine.block.setFloat(arrow, 'transform/anchor/x', 80);
engine.block.setFloat(arrow, 'transform/anchor/y', 20);
engine.block.setVec2(arrow, 'position', { x: 700, y: 140 });
engine.block.setKeyframes(arrow, 'position', [
{
time: 0,
value: { type: 'Vec2', value: { x: 700, y: 140 } },
spatialOut: { x: 300, y: 150 },
spatialIn: { x: -300, y: -150 }
},
{ time: 1, value: { type: 'Vec2', value: { x: 1600, y: 140 } } }
]);
engine.block.setBool(arrow, 'transform/autoOrient', true);

The arrow turns about its anchor at its center. For a block without an anchor, the skew axis, animating the skew, how auto-orient adds to the rotation and the errors to expect, see Anchor, Skew and Auto-Orient.

Follow Another Block#

setTransformParent() makes a block follow the transform of another block, so it moves with the block also while that block plays keyframes. In the example, the square rides along the arrow’s path. getTransformParent() returns the transform parent, or null.

// A square rides in front of the arrow. Its position is read in the
// arrow's local space, so it moves and turns with the arrow.
const rider = engine.block.create('graphic');
engine.block.setShape(rider, engine.block.createShape('rect'));
const riderFill = engine.block.createFill('color');
engine.block.setColor(riderFill, 'fill/color/value', {
r: 0.95,
g: 0.7,
b: 0.1,
a: 1
});
engine.block.setFill(rider, riderFill);
engine.block.setWidth(rider, 40);
engine.block.setHeight(rider, 40);
engine.block.setPositionX(rider, 170);
engine.block.setPositionY(rider, 0);
engine.block.appendChild(page, rider);
engine.block.setTransformParent(rider, arrow);
console.log(engine.block.getTransformParent(rider) === arrow); // true
try {
// The arrow cannot follow a block that follows the arrow
engine.block.setTransformParent(arrow, rider);
} catch (error) {
const { code } = error as { code?: string };
console.log(code); // 'BLOCK.TRANSFORM_PARENT_CYCLE'
}

For what a block inherits from its transform parent, chains of links, removing a link and the errors to expect, see Follow Another Block.

Reading Keyframes Back#

getAnimatedProperties() lists the paths of a block that have keyframes, sorted. getKeyframes() returns the keyframes of a property sorted by time, with the default easing filled in. It returns an empty array for an animatable property without keyframes. getKeyframeMode() returns 'Absolute' or 'Relative'.

console.log(engine.block.getAnimatedProperties(block));
// ['opacity', 'position/x', 'stroke/enabled']
const opacityKeyframes = engine.block.getKeyframes(block, 'opacity');
console.log(opacityKeyframes[1].easing);
// { type: 'Preset', preset: 'Linear' }, the default
console.log(engine.block.getKeyframeMode(block, 'position/x')); // 'Relative'

getKeyframeMode() throws for a property without keyframes, so check getAnimatedProperties() first when you do not know.

Static Values and Keyframes#

Setters such as setOpacity() or setFloat() change the static value and leave the keyframes as they are. setKeyframes() does not change the static value either.

// Changes the static value only. The absolute opacity track still replaces it.
engine.block.setOpacity(block, 0.5);
console.log(engine.block.getKeyframes(block, 'opacity').length); // 2

An absolute track replaces the static value, so the new opacity does not show while the track exists. A relative track adds to it.

Removing Keyframes#

removeKeyframes() removes the track of one property, so the static value applies again. Removing from a property without keyframes does nothing. To change single keyframes, read the track with getKeyframes(), edit the array and pass it to setKeyframes().

// The bottom corners return to their static radius of 0
engine.block.removeKeyframes(shape, 'shape/rect/cornerRadiusBL');
engine.block.removeKeyframes(shape, 'shape/rect/cornerRadiusBR');
console.log(engine.block.getAnimatedProperties(shape));
// ['shape/rect/cornerRadiusTL', 'shape/rect/cornerRadiusTR']

Handling Errors#

Invalid input throws an engine error. Its code names the problem. A failed setKeyframes() leaves the existing track unchanged.

try {
engine.block.setKeyframes(block, 'visible', [
{ time: 0, value: { type: 'Discrete', value: true } }
]);
} catch (error) {
// Engine errors carry a stable catalog id in `code`
const { code } = error as { code?: string };
console.log(code); // 'BLOCK.KEYFRAMES_PROPERTY_NOT_ANIMATABLE'
}

Exporting a Frame#

We set the page’s playback time to 2.5 seconds and export the page as PNG. The export shows every track at that time: the block is halfway along its path, purple, with a stroke and rounded top corners.

// Export the frame at 2.5 seconds, halfway through the animation
engine.block.setPlaybackTime(page, 2.5);
const outputDir = './output';
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
const blob = await engine.block.export(page, { mimeType: 'image/png' });
const buffer = Buffer.from(await blob.arrayBuffer());
writeFileSync(`${outputDir}/keyframes.png`, buffer);
console.log('Exported result to output/keyframes.png');

Keyframes and Other Block Operations#

Keyframes take part in the usual block operations:

  • Undo and redo restore keyframe tracks.
  • Saving and loading a scene keeps the tracks, their easing and their mode.
  • Duplicating a block copies the tracks of the block, its fill and its shape.
  • Replacing a fill, shape or effect does not move the keyframes to the new block. They stay with the old one.
  • Preset animations run on top of keyframe tracks. A fade animation scales the keyframed opacity. A scale animation (Zoom, Pop, Grow, Scale Loop, Squeeze) ignores the static or keyframed transform/scale of the block while it runs. Pulsating and Breathing scale the static or keyframed scale, so a flipped block stays flipped.
  • Scaling a block or changing the design unit also converts the keyframes of length and position properties, such as corner radii and position/x.
  • Rotating or flipping a block turns or mirrors the keyframes of its position, rotation and skew with it. The keyframes of a group turn and mirror with the group. A turn other than a half turn joins position/x and position/y tracks into one position track when they share the mode, the times and the easings and no position track exists. Other axis tracks cannot turn: their Absolute values move with the block, and their Relative values stay.

setKeyframes() and removeKeyframes() need the appearance/animation scope and the scope of the property’s setter, such as layer/move for position, layer/rotate for rotation and layer/resize for width and the scale. A transform/scale/x or transform/scale/y track that shows another sign than the static scale also needs layer/flip. A transform lock (setTransformLocked()) does not apply to keyframes: tracks on the position, rotation, size, scale, anchor or skew still animate a locked block.

Troubleshooting#

  • The property does not accept keyframes (BLOCK.KEYFRAMES_PROPERTY_NOT_ANIMATABLE): check isPropertyAnimatable() and pass the block that owns the property, for example the fill for fill/color/value. All keyframe methods throw this error for such a property.
  • The keyframe list is empty (BLOCK.KEYFRAMES_EMPTY): pass at least one keyframe. Use removeKeyframes() to remove a track.
  • A time is outside 0 to 1 (BLOCK.KEYFRAMES_TIME_OUT_OF_RANGE): times are fractions of the block’s duration, not seconds.
  • Two keyframes have the same time (BLOCK.KEYFRAMES_DUPLICATE_TIME): give each keyframe its own time.
  • The value does not fit the property (BLOCK.KEYFRAMES_VALUE_KIND_MISMATCH): use the value type of the property, for example 'Color' for fill/color/value.
  • The keyframes hold different value types (BLOCK.KEYFRAMES_MIXED_VALUE_KINDS): use one value type for the whole track.
  • The colors use different color spaces (BLOCK.KEYFRAMES_MIXED_COLOR_SPACES): use one color space per track. For a spot color, keep one spot color name and animate its tint.
  • A bezier handle is out of range (BLOCK.KEYFRAMES_EASING_OUT_OF_RANGE): keep the x values of out and in from 0 to 1.
  • The relative mode fails (BLOCK.KEYFRAMES_RELATIVE_UNSUPPORTED): use 'Relative' only for number and vec2 properties.
  • Motion path tangents fail (BLOCK.KEYFRAMES_SPATIAL_TANGENT_UNSUPPORTED): spatialOut and spatialIn work only on keyframes with a 'Vec2' value.
  • A name is not recognized (BLOCK.KEYFRAMES_ENUM_OUT_OF_RANGE): check the spelling of the mode, the easing type and the preset. A discrete number must be a whole 32-bit number.
  • Reading the mode fails (BLOCK.KEYFRAMES_NO_TRACK): the property has no keyframes.
  • A block cannot follow another block (BLOCK.TRANSFORM_PARENT_CYCLE): the parent is the block itself, sits inside it or follows it, directly or through other blocks. Pick a parent outside that chain.

API Reference#

Method Description
block.isPropertyAnimatable(block, property) Check whether a property of a block accepts keyframes
block.setKeyframes(block, property, keyframes, mode?) Replace the keyframe track of a property
block.getKeyframes(block, property) Get the keyframes of a property, sorted by time
block.getKeyframeMode(block, property) Get 'Absolute' or 'Relative' for a track
block.getAnimatedProperties(block) List the properties of a block that have keyframes
block.removeKeyframes(block, property) Remove the keyframe track of a property
block.setVec2(block, property, value) Set the static value of position or shape/position
block.setFloat(block, property, value) Set transform/anchor/x, transform/anchor/y, transform/skew or transform/skewAxis
block.setBool(block, 'transform/autoOrient', value) Turn a block in the direction of its position keyframes
block.setTransformParent(block, parent) Make a block follow the transform of another block, or pass null to remove the link
block.getTransformParent(block) Get the block that a block follows, or null
block.hasTransformAnchor(block) Check whether a block has an anchor, the pivot of a rotation track

Next Steps#