Skip to content

Time

An Element’s duration is how long it takes from its first frame to its last, 1 second if left out. Its delay is how long it waits before its first frame, holding that frame until then.

A Burst has no duration of its own: it lasts until its latest Child ends, counting each Child’s stagger offset, delay and duration, all the way down through nested Bursts. So changing one Child’s duration changes the Burst’s, and under @motly/gsap the tween’s.

import { each, Motly } from '@motly/gsap';
import { gsap } from 'gsap';
gsap.registerPlugin(Motly);
// Every other Child waits 0.3 s. The Burst lasts until its latest Child ends: 0.3 + 0.8 s.
const spec = {
kind: 'burst',
count: 12,
radius: [0, 100],
restAt: 0.5,
children: {
kind: 'circle',
radius: [8, 0],
fill: each(['deeppink', 'gold']),
delay: each([0, 0.3]),
duration: 0.8,
},
};
gsap.effects.burst('.origin', { spec, repeat: -1, repeatDelay: 0.5 });

A Burst’s own delay holds back the whole Burst: nothing is thrown until it has passed. Offset Children with their own delay, as above, or all of them in turn with stagger.

stagger on a Burst starts each Child a fixed time after the one before it, in ray order: from the first ray, clockwise. Each Child is thrown from the Origin when it starts, so the burst ripples out instead of popping.

import { Motly } from '@motly/gsap';
import { gsap } from 'gsap';
gsap.registerPlugin(Motly);
// Each Child starts 0.05 s after the one before it, clockwise from 12 o'clock.
const spec = {
kind: 'burst',
count: 16,
stagger: 0.05,
radius: [0, 100],
restAt: 0.6,
children: { kind: 'circle', radius: [7, 0], fill: '#06d6a0', duration: 0.7 },
};
gsap.effects.burst('.origin', { spec, repeat: -1, repeatDelay: 0.5 });

{ each, easing } keeps the same span of starts, each × (count − 1), but spreads them along a Curve, so a burst can land unevenly on purpose. A start the Curve puts before 0 is held at 0.

import { expoIn, Motly } from '@motly/gsap';
import { gsap } from 'gsap';
gsap.registerPlugin(Motly);
// The same span of starts, spread along a curve: slow to begin, then all at once.
const spec = {
kind: 'burst',
count: 16,
stagger: { each: 0.05, easing: expoIn },
radius: [0, 100],
restAt: 0.6,
children: { kind: 'circle', radius: [7, 0], fill: '#118ab2', duration: 0.7 },
};
gsap.effects.burst('.origin', { spec, repeat: -1, repeatDelay: 0.5 });

Under @motly/gsap, stagger in vars is not this: GSAP’s stagger across targets is ignored with a warning. spec.stagger staggers the Children of each burst.

In @motly/core, an Instance plays itself: play() starts from the beginning and returns a promise that settles the first time the Playhead reaches the end moving forward. It never rejects: destroy() before the end settles it too.

Method What it does
play() Move the Playhead to 0 and play forward.
pause() Stop the Playhead where it is.
resume() Carry on from where pause() left it, in the direction it was moving.
reverse() Play backward from where it is, stopping at 0. Reaching 0 settles nothing.
seek(t) Move the Playhead to t seconds and draw there now, playing or paused as before.
setProgress(p) seek() by progress, 0 to 1: for a slider or a scroll position.
destroy() Stop, and remove everything drawn.
import { Burst, rand } from '@motly/core';
import { SVGRenderer } from '@motly/core/svg';
const renderer = new SVGRenderer(document.querySelector('svg'));
const burst = new Burst(
{
kind: 'burst',
count: 14,
radius: [0, 90],
restAt: 0.4,
children: {
kind: 'star',
points: 5,
radius: [rand(5, 11), 0],
angle: [0, rand(-180, 180)],
fill: 'gold',
duration: 2,
},
},
{ renderer, origin: { x: 150, y: 100 }, seed: 5 },
);
for (const button of document.querySelectorAll('button')) {
button.addEventListener('click', () => burst[button.dataset.action]());
}
const slider = document.querySelector('input');
slider.addEventListener('input', () => burst.setProgress(slider.valueAsNumber));
burst.setProgress(slider.valueAsNumber);

An Instance is drawn only from its Playhead, never from a clock, so the same Playhead always draws the same frame: scrubbing back and forth never changes what you see.

Under @motly/gsap there are no such methods to call: the effect returns a GSAP tween, and the tween’s own play(), reverse(), progress() and the rest move the burst.

A Timeline sequences several Instances on one Playhead, so one play(), seek() or reverse() moves them all. Create it from a Scope, and create its Instances with its own shape() and burst(). Leave out the last argument, at, to place an Instance after everything on the Timeline so far; pass it, in seconds, to overlap.

import { createScope, each, rand } from '@motly/core';
import { SVGRenderer } from '@motly/core/svg';
const renderer = new SVGRenderer(document.querySelector('svg'));
const scope = createScope();
const timeline = scope.timeline();
const ring = {
kind: 'circle',
radius: [0, 50],
fill: 'none',
stroke: '#ffd166',
strokeWidth: [10, 0],
duration: 0.6,
};
const sparks = {
kind: 'burst',
count: 10,
radius: [0, 60],
children: {
kind: 'circle',
radius: [rand(3, 7), 0],
fill: each(['#ef476f', '#06d6a0']),
duration: 0.7,
},
};
// Left out, `at` places an Instance after everything so far; given, it overlaps at that second.
timeline.shape(ring, { renderer, origin: { x: 80, y: 110 } });
timeline.burst(sparks, { renderer, origin: { x: 210, y: 110 } });
timeline.burst(sparks, { renderer, origin: { x: 340, y: 110 } }, 0.8);
timeline.play();

An Instance on a Timeline follows the Timeline only: its own playback methods do nothing, though awaiting its play() still settles when the Timeline carries it past its end. Before its start it holds its first frame, and after its end its last.

A Scope owns what it creates: scope.destroy() removes every Instance and Timeline in it at once. Under @motly/gsap, use a GSAP timeline and tl.burst() instead.

What moves the Playhead is a Driver. Standalone, core’s own requestAnimationFrame loop does. When something in your app already decides time, pass your own Driver to a Scope, and every Instance in it follows that instead:

import { createScope } from '@motly/core';
const scope = createScope({ driver: myDriver });

A Driver has one method, attach(target). It takes charge of the target’s Playhead and returns the Playback controls above; the target gives its duration, draws with render(t), and settles its play() with finish(). @motly/gsap does the same job without one: GSAP’s ticker decides time, and the tween seeks each burst’s Playhead as it renders.

A viewer who prefers reduced motion sees one still frame instead of the motion: the Resting frame, at restAt, a progress from 0 to 1. Left out, it is the last frame, which for a Burst is usually empty, so give every Burst a restAt where it still shows something. restAt is read from the Spec you create the Instance from, not from a Child’s.

import { Burst, rand } from '@motly/core';
import { SVGRenderer } from '@motly/core/svg';
const spec = {
kind: 'burst',
count: 12,
radius: [0, 80],
// Where the Resting frame is, as a progress from 0 to 1. Its last frame would be empty.
restAt: 0.4,
children: { kind: 'circle', radius: [rand(4, 9), 0], fill: 'gold', duration: 1.2 },
};
for (const [id, reducedMotion] of [
['#moving', 'never'],
['#resting', 'always'],
]) {
const renderer = new SVGRenderer(document.querySelector(id));
const burst = new Burst(spec, { renderer, origin: { x: 110, y: 110 }, reducedMotion, seed: 3 });
burst.play();
}

In @motly/core, reducedMotion in the second argument is 'user' by default, which follows the viewer’s setting, read at each play(). 'always' and 'never' force it, for a demo or a test. Under reduced motion, play() draws the Resting frame and settles at once, resume() does nothing, and reverse() jumps to the first frame. A Timeline takes reducedMotion too, in scope.timeline({ reducedMotion }), and decides for every Instance on it.

Under @motly/gsap, the Resting frame is shown for the tween’s full length, so everything after it in a timeline keeps its timing. See reduced motion in the GSAP guide.