Animations
Ripl provides two approaches to animation: manual transitions using the standalone transition function, and renderer-based transitions using renderer.transition(). Both are promise-based and support easing, keyframes, and custom interpolators.
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, 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 normalised 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 — 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 13 built-in easing functions covering Quad, Cubic, Quart, and Quint curves, each available in In, Out, and InOut variants, plus easeLinear for constant speed.
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.