Skip to main content

Animation

StageRoutine makes animations declarative. You define target values with to() and link transitions with milestones.

Property Transitions​

To animate an element property, assign a to(target) descriptor:

// Move to x: 80 over 0.8 seconds with custom easing
title.x = to(80).duration(0.8).ease("quartOut");
title.opacity = to(1).duration(0.5);

Supported Animated Properties​

  • Spatial: x, y, scale, rotation, width, height
  • Visual: opacity, blur, brightness, color

Local transforms​

Use transform() to tilt any element. Create one controller for each element:

import { transform } from "stageroutine";

const turn = transform({ perspective: 220 });
photo.decorate(turn);

turn.to({ rotationX: -6, rotationY: 12 }).duration(1.2);

rotationX, rotationY, and rotation use degrees and default to 0. The effect's scale is a multiplier with a default of 1. The element's origin selects the pivot. Its default is "top-left"; use "center" for a centered turn.

Perspective and depth​

Each controller has its own perspective distance. The default is 220 stage units. Smaller positive distances exaggerate distortion. Larger distances produce a gentler effect. Use "none" for orthographic projection: rotation still foreshortens the element, without depth enlargement.

const chartTurn = transform({ perspective: 220 });
const frameTurn = transform({ perspective: 600 });
chart.decorate(chartTurn);
frame.decorate(frameTurn);

z controls local depth. It defaults to 0 stage units. Positive depth enlarges the element; negative depth shrinks it. Its pivot stays at its stage position. Keep depth and every rotated corner below the perspective distance.

chartTurn.to({ z: 30, rotationY: -15 }).duration(1);

Coordinate movement and effects​

Move the element with its normal properties. Animate the effect with its controller. Moving an element across the stage preserves its local perspective.

photo.to({ position: [100, 55] }).duration(1);
turn.to({ rotationY: -25 }).duration(0.8).after(photo, "position");

Both use the stage clock and snapshots. Going backward restores movement and effect values together. Controllers stop with their transitions. They create no animation loop or layout container. zIndex controls stacking; local depth does not reorder elements.

Apply a transform to a media frame to turn the entire object. A Ken Burns effect can continue to pan and zoom the content inside that frame.

Measure transformed geometry​

Use space.corners() when you need the projected outline. Pass the same authored geometry and effect values:

import { space } from "stageroutine";

const corners = space.corners({
position: [100, 55],
size: [60, 33.75],
origin: "center",
transform: turn.values,
});
const bounds = space.bounds(corners);

Positions, sizes, and returned bounds use stage units. The default origin is "top-left". turn.values is a read-only copy of the current effect values. Local rotations apply in X, Y, Z order, before the element's own scale, 2D rotation, and stage placement. Corners retain their source order: top-left, top-right, bottom-right, bottom-left.

Layout arranges authored rectangles. Connectors and bubble tails follow the rendered attachment points. CSS clips parts that cross the eye plane; space.corners() throws when a corner cannot be projected.

Relative Transitions​

Instead of setting absolute values, pass an updater function to adjust properties relative to their current state.

Property Deltas​

Pass a callback taking the current numeric value:

// Shift y position 10% upwards over 0.4 seconds
card.y = to((y) => y - 10).duration(0.4).ease("quartOut");

// Scale element up by 20%
card.scale = (s) => s * 1.2;

2D Position Deltas​

Pass a callback taking current (x, y) coordinates to update the position tuple:

// Move 5% right and 10% up
card.position = to((x, y) => [x + 5, y - 10]).duration(0.5);

Circular movement​

Import motion and select a route with .path():

import { motion, to } from "stageroutine";
card.position = to([100, 50]).path(motion.arc(90)).duration(1.2);

The sweep defaults to 0 degrees, which moves straight. Use -180 to 180 degrees. Positive sweeps turn clockwise; negative sweeps turn counterclockwise. For a left-to-right move, a positive sweep bends above the straight route. Duration defaults to 0.6 seconds and easing defaults to "cubicOut". Easing controls speed along the circle. The element keeps its rotation. An unchanged position produces no movement.

Bézier curves​

Use two absolute control points to shape a swoop or an S-shaped flight. All points use stage units. The transition supplies the start and destination.

import { motion, to } from "stageroutine";

card.position = to([120, 50])
.path(motion.bezier({
control1: [45, 15],
control2: [95, 85],
}))
.duration(1.2);

The first control sets the departure direction. The second sets the approach direction. Controls are required. A curve can return to its starting position.

Spiral arrivals​

Wind around the destination while the radius decreases to zero:

card.position = to([100, 50])
.path(motion.spiral({ turns: 0.75 }))
.duration(1.5);

turns defaults to 1 clockwise revolution. Negative values turn counterclockwise. The range is -10 to 10 turns; 0 turns moves straight. An unchanged destination produces no spiral movement.

Bézier and spiral routes use approximate distance-based progress. With ease("linear"), travel speed stays approximately constant. Other easing curves change speed along the route. Elements keep their rotation.

You can reuse a route across transitions. Built-in helpers copy fixed options.

Custom routes​

A MotionPath receives the start and destination in stage units. It returns a pure function that samples the route from t = 0 to t = 1. The sampled endpoints must match the supplied start and destination. The engine applies easing to travel distance, with approximately constant speed for "linear". Easing overshoot continues along the endpoint tangent.

import { type MotionPath, to } from "stageroutine";

const hop = (height: number): MotionPath => (from, to) => t => [
from[0] + (to[0] - from[0]) * t,
from[1] + (to[1] - from[1]) * t - 4 * height * t * (1 - t),
];

const lift = hop(20); // Height: 20 stage units.
card.position = to([100, 50]).path(lift);

Reuse lift with other destinations or layout callbacks. Capture fixed configuration. Do not read changing state, use clocks, or mutate elements in a route. Backward navigation restores positions from snapshots.

Bézier controls can also resolve from each movement's endpoints:

const swoop = motion.bezier((from, to) => ({
control1: [from[0], from[1] - 20],
control2: [to[0], to[1] - 20],
}));
card.position = to([100, 50]).path(swoop);

Fly into a layout​

The animation callback receives the complete destination in stage units, the element, and its index. Use a delay to send elements along separate arcs:

layout.grid([[a, b], [c, d]], {
x: 80,
y: 30,
animate: (position, element, index) =>
to(position).path(motion.arc(90)).duration(1.2).delay(index * 0.08),
});

Move a formation​

layout.group() moves the group center along the selected path and preserves each element's offset. Its callback runs once, with the center destination. A requested scale changes the offsets and element sizes together.

Choose Arc, Bézier, or Spiral, then fly into a grid or move the formation together. The flight buttons reset the cards before starting.

layout.grid([[cards[0], cards[1]], [cards[2], cards[3]]], {
x: 108,
y: 50,
gap: 8,
animate: (position, element, index) =>
to(position).path(route).duration(1.5).delay(index * 0.08),
});
layout.group(cards, {
position: [126, 57],
animate: (position) => to(position).path(route).duration(1.5),
});

Interrupt movement​

A new position transition starts from the visible position. Assigning x or y cancels a position transition and keeps the other visible coordinate. Delayed transitions read their starting position when movement begins. Navigation cancels interaction animations and restores the destination snapshot. Going backward restores coordinates without replaying the path in reverse. Replacing movement preserves position, but can change direction and speed immediately.

When defining a step, the last overlapping assignment wins. Replacing a position transition with x or y restores its starting coordinates before applying the new assignment.

Milestone Synchronization​

Synchronize animations relative to other elements without doing millisecond math:

Milestone Synchronization

// Animate title first
title.opacity = to(1).duration(1.0);

// Start code halfway through title's animation (at 0.5s)
code.opacity = to(1).duration(0.6).when(title, "halfway");

// Start kicker immediately when title finishes (at 1.0s)
kicker.opacity = to(1).when(title, "end");

Available Milestones​

  • "start" (or 0): Starts at the same time as target animation
  • "halfway" (or 0.5): Starts when target reaches 50% progress
  • "end" (or "complete"): Starts when target finishes

Multi-Property Animations​

Animate multiple properties on an element simultaneously with element.to():

codePanel.to({
y: (y) => y - 10,
opacity: 0,
}).duration(0.4).ease("cubicInOut");

Changing Text​

Use .to({ text }) to crossfade a title, text, kicker, or shape label. The component keeps its background, children, and identity. Timing uses the same duration, delay, and easing controls as other properties.

heading.to({ text: "Then reality joins the rehearsal" }).duration(0.4);

Duration is in seconds. The default is 0.6 seconds. Assign heading.text = "Ready" for an immediate change. Use replace() to swap complete components, including their child DOM.

Scene Choreography​

StageRoutine provides high-level helpers to choreograph elements across scenes:

Staggered Cascades​

Cascade property changes across an array of elements with stagger():

import { stagger } from "stageroutine";

stagger([item1, item2, item3], {
duration: 0.3,
overlap: 0.5,
});

In-Place Replacement​

Swap one element with another smoothly in-place:

import { replace } from "stageroutine";

replace(oldDiagram, newDiagram, { duration: 0.6 });

Group Operations​

Treat multiple elements as a single target for transitions, property mutations, or decorators with group():

import { group, to } from "stageroutine";

const leftColumn = group(heading, text, rule);

// Animate multiple elements simultaneously
leftColumn.to({
y: (y) => y - 10,
opacity: 0,
}).duration(0.4).ease("cubicInOut");

// Broadcast property values or transitions
leftColumn.opacity = 0;
leftColumn.opacity = to(0).duration(0.4);

// Chain milestone triggers when the group completes
nextElement.opacity = to(1).after(leftColumn);

group() creates a zero-DOM proxy. It does not wrap elements in container <div> nodes, keeping stage elements flat siblings.

Custom Easing Curves​

In addition to built-in presets (like "quartOut" and "cubicInOut"), you can supply custom easing functions mapping progress t from 0 to 1:

// Smooth cosine curve
title.y = to(36).ease((t) => 0.5 * (1 - Math.cos(Math.PI * t)));

Animate on interaction​

Use the same to() assignments in a click handler. The default duration is 0.6 seconds.

card.onClick(() => {
card.scale = to(1.2).duration(0.4);
return () => {
card.scale = to(1).duration(0.4);
};
});

Click outside the element or press Escape to run the returned cleanup. Navigation cancels interactive animations and restores the destination step. Animated assignments during scene transitions are ignored. Assignments made together share delays and milestone dependencies. Use .duration(0) to apply a target without interpolation. Animation work stops when the targets are reached.

Animation frame rate​

Scene and interactive transitions follow the display refresh rate by default ("auto"). Set a positive frame rate in frames per second to limit their updates:

const stage = new Stage({ frameRate: 30 });

// Change the limit during playback.
stage.frameRate = 60;
stage.frameRate = "auto";

Durations stay the same. Transitions always apply their final values when they finish. The setting stays in effect when navigating backward or forward. Backgrounds keep their own frame-rate limits. CSS animations and video playback keep their own timing. This setting does not synchronize the presentation with recording or screen sharing.