Skip to content

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.

ts
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:

ts
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.

ts
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); // true
  • pause() — stops the animation frame loop but keeps the promise pending
  • play() — resumes from the paused position
  • seek(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:

ts
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:

ts
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.

ts
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:

ts
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:

ts
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:

ts
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.

ts
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:

ts
// 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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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.

Looping Demo