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

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

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, so 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 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.

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