Animations
Ripl provides two approaches to animation: manual transitions using the standalone transition function, and renderer-based transitions using renderer.transition().
Both return a Transition, which is a Promise you can await and a cancelable task you can abort(), pause(), play() and seek(). Both take a duration, a delay, an ease (31 easing functions ship with the library, from easeLinear through the quad/cubic/quart/quint/sine/expo/circ families to back, bounce and elastic), a direction of 'forward' or 'reverse', and a loop mode that either restarts or ping-pongs. renderer.transition() adds keyframes with implicit or explicit offsets, per-element callbacks for staggering, and custom interpolator functions per property.
Demo
NOTE
For the full API, see the Core API Reference.
Manual Transitions
The transition function runs a timed animation loop using requestAnimationFrame. It's useful when you don't have a scene/renderer setup, for example when animating a single element rendered directly to a context.
import {
easeOutCubic,
transition,
} from '@ripl/web';
await transition({
duration: 1000,
ease: easeOutCubic,
}, (t) => {
// t goes from 0 to 1 over the duration
circle.radius = 50 + t * 50;
context.clear();
circle.render(context);
});The transition accepts duration (milliseconds), ease (easing function, defaults to easeLinear), and loop (repeat indefinitely).
Aborting a Transition
The transition function returns a Transition object (which extends Task). You can abort it:
const t = transition({ duration: 2000 }, (t) => {
circle.radius = 50 + t * 50;
});
// Abort after 500ms
setTimeout(() => t.abort(), 500);Playback Control
A Transition can be paused, resumed, and seeked to any position. These methods are chainable and do not resolve the underlying promise until the transition completes normally.
const t = transition((time) => {
circle.radius = 50 + time * 50;
context.clear();
circle.render(context);
}, { duration: 2000 });
// Pause after 500ms
setTimeout(() => t.pause(), 500);
// Resume 1 second later
setTimeout(() => t.play(), 1500);
// Jump to 75% and pause
t.seek(0.75);
// Check state
console.log(t.paused); // truepause()stops the animation frame loop but keeps the promise pendingplay()resumes from the paused positionseek(position)jumps to a normalized position (0–1), invokes the callback once with the eased time at that position, and pauses
Reversing a Transition
Every Transition exposes an inverse property: a factory function that creates a new Transition running in the opposite direction. Both the standalone transition() function and renderer.transition() set this automatically:
const grow = transition((t) => {
circle.radius = 50 + t * 50;
}, {
duration: 800,
ease: easeOutCubic,
});
await grow;
// Shrink back
await grow.inverse();For renderer transitions, inverse re-schedules the same element transition with the flipped direction:
const t = await renderer.transition(circle, {
duration: 800,
ease: easeOutCubic,
state: { radius: 100 },
});
// Reverse back to original state
await t.inverse();Renderer Transitions
When working with a scene and renderer, use renderer.transition() for a higher-level API that handles interpolation, re-rendering, and multi-element animations automatically.
import {
createRenderer,
createScene,
easeOutCubic,
} from '@ripl/web';
const scene = createScene('.container', { children: [circle] });
const renderer = createRenderer(scene);
await renderer.transition(circle, {
duration: 1000,
ease: easeOutCubic,
state: {
radius: 100,
fill: '#ff006e',
},
});The renderer automatically:
- Interpolates each property using the appropriate interpolator
- Re-renders the scene each frame
- Starts/stops the render loop as needed (with
autoStart/autoStop)
Looping
Both transition() and renderer.transition() support looping via the loop option. A looping transition repeats indefinitely and never resolves, so you must call abort() to stop it.
Restart Loop
Set loop: true to restart the animation from the beginning each iteration:
const t = renderer.transition(circle, {
duration: 1000,
ease: easeInOutQuad,
loop: true,
state: { radius: 100 },
});
// Stop after 5 seconds
setTimeout(() => t.abort(), 5000);Alternate (Ping-Pong) Loop
Set loop: 'alternate' to reverse direction each iteration, creating a ping-pong effect:
const t = renderer.transition(circle, {
duration: 1000,
ease: easeInOutQuad,
loop: 'alternate',
state: { cx: 300 },
});
// Stop after 5 seconds
setTimeout(() => t.abort(), 5000);Looping also works with the standalone transition function:
const t = transition((time) => {
circle.radius = 50 + time * 50;
context.clear();
circle.render(context);
}, {
duration: 1000,
loop: 'alternate',
});TIP
Looping transitions support pause(), play(), and seek(). Playback control operates within the current loop iteration.
Easing Functions
Easing functions control the rate of change over time. Ripl ships with 31 built-in easing functions: easeLinear for constant speed, the Quad, Cubic, Quart, Quint, Sine, Expo, and Circ curves, plus the Back, Elastic, and Bounce families, each available in In, Out, and InOut variants.
import {
easeOutCubic,
} from '@ripl/web';
await renderer.transition(circle, {
duration: 800,
ease: easeOutCubic,
state: { radius: 100 },
});Custom Easing
An easing function takes a value from 0–1 and returns a transformed value:
// Bounce easing
const easeBounce = (t: number) => {
const n1 = 7.5625;
const d1 = 2.75;
if (t < 1 / d1) return n1 * t * t;
if (t < 2 / d1) return n1 * (t -= 1.5 / d1) * t + 0.75;
if (t < 2.5 / d1) return n1 * (t -= 2.25 / d1) * t + 0.9375;
return n1 * (t -= 2.625 / d1) * t + 0.984375;
};
await renderer.transition(circle, {
duration: 1000,
ease: easeBounce,
state: { cy: 250 },
});Chaining Animations
Transitions are awaitable, so you can chain them sequentially:
async function animate() {
await renderer.transition(circle, {
duration: 500,
ease: easeOutCubic,
state: {
radius: 100,
fill: '#ff006e',
},
});
await renderer.transition(circle, {
duration: 500,
ease: easeInOutQuad,
state: {
radius: 50,
fill: '#3a86ff',
},
});
}Parallel Animations
Run multiple transitions simultaneously with Promise.all:
await Promise.all([
renderer.transition(circle, {
duration: 800,
ease: easeOutCubic,
state: { cx: 300 },
}),
renderer.transition(rect, {
duration: 800,
ease: easeOutCubic,
state: { x: 100 },
}),
]);Keyframe Animations
Transitions support CSS-like keyframe arrays for multi-step animations within a single transition.
Implicit Offsets
Values are evenly distributed across the duration:
await renderer.transition(circle, {
duration: 2000,
state: {
fill: [
'#3a86ff', // offset 0.33
'#ff006e', // offset 0.66
'#8338ec', // offset 1.0
],
},
});Explicit Offsets
Specify exact positions for each keyframe:
await renderer.transition(circle, {
duration: 2000,
state: {
fill: [
{
value: '#ff006e',
offset: 0.25,
},
{
value: '#8338ec',
offset: 0.5,
},
{
value: '#3a86ff',
offset: 1.0,
},
],
},
});Custom Interpolator Functions
Pass a function instead of a target value for full control over the interpolation:
await renderer.transition(circle, {
duration: 1000,
state: {
// t goes from 0 to 1 (after easing)
radius: t => 50 + Math.sin(t * Math.PI * 4) * 20,
},
});Playback Control Demo
Use the controls below to start a transition, then pause, seek, and reverse it interactively.