Animation sequencing is a common technique that allows creation of complex animated effects where multiple effects (potentially on multiple targets) are animated together as a group.
There is currently a draft proposal to include some form of sequencing in Web Animations Level 2 Specification. The proposal introduces a GroupEffect
which is a type of AnimationEffect
that contains an ordered sequence of other animation effects a.k.a children effects.
In the explainer we are proposing a few changes to this model with the objective to introduce flexibility and customizability. Furthermore we propose adding a new sequencing model “stagger”. The altered design introduces a different hierarchy of group effect: An abstract group effect representing the grouping, and concrete sub-types that define different possible mapping of time from the parent to children.
Grouping is a powerful mechanism that allows creation of complex tree structures by hiding the complexity inside the group. Note that for this to work well the group should hide its inner working and expose a common API. This is a valuable primitive that allows scaling animations to complex sequences and it is a common technique supported by popular animation libraries and tools.
With current level-1 web animations specification, to create a coordinated sequence of animations a web developer has to create multiple animations and each of them is associated with only one effect and target.
This can work but suboptimal for the following reasons.
GroupEffect
.Consider this sequence where two elements are animated and then reversed. A possible implementation without grouping can be done this way:
// Without GroupEffect
var effect_a = new KeyframeEffect(
target_a, [{ opacity: 0, offset: 0},
{ opacity: 1, offset: 0.5},
{ opacity: 1, offset: 1}], {
duration: 200, iterations: 2, direction: 'alternate'});
var effect_b = new KeyframeEffect(
target_b, { opacity: [0, 1] }, {
duration: 200, iterations: 2, direction: 'alternate' });
var animation_a = new Animation(effect_a, document.timeline);
var animation_b = new Animation(effect_b, document.timeline);
animation_a.play();
animation_b.play();
There are several drawbacks in the example above:
With grouping the code can be described more naturally as:
// With GroupEffect the effects don't need to have exact durations.
var effect_a = new KeyframeEffect(
target_a, { opacity: [0, 1] }, {duration: 100, fill: 'forwards' });
var effect_b = new KeyframeEffect(
target_b, { opacity: [0, 1] }, {duration: 200});
var parallel_effect = new ParallelEffect([effect_a, effect_b],
{iterations: 2, direction: 'alternate'});
var animation = new Animation(parallel_effect, document.timeline);
animation.play()
The key insight of the current design is to allow grouping of the animation effects. We believe this is the right choice but propose some changes in the hierarchy of classes.
In the current design the base class (GroupEffect
) comes with a default time mapping from the group time to the child. This default choice is parallel. i.e. children have the same start time
as the group. Then there is a special group effect: sequence effects
which overrides the time mapping to arranges the start time of the children effects in a way that they start in turn.
We propose to separate the concepts of “group of effects” from “time mapping” thus making it more natural to later add grouping with custom time mappings. In this design the base class, GroupEffect
, has no time mapping and cannot be directly constructed. Instead, concrete subclasses (SequenceEffect
, ParallelEffect
, StaggerEffect
) have time mappings. In particular, in future AnimationWorklet can be used to allow a special WorkletGroupEffect whose time can be mapped to its children in any fashion.
Also, the existing spec only speculates two different scheduling models (i.e. parallel, sequence) and we believe stagger is a nice addition. With polyfill, a nice animation is created using StaggerEffect. See more examples of stagger in popular frameworks.
GroupEffect
: an abstract class that contains an ordered list of “child effects” but not time mapping from parent to children and not intrinsic iteration duration. There are some concrete subclasses which have a specific time mapping model. These models determine
Note that the basic supported types map have a simple time shift model which addresses most common use cases.
Notes:
AnimationWorklet
to allow creation of interesting new Groupings.AnimationWorklet
where we allow animation worklet to create custom sequencing models.GroupEffect
is a “compositing mechanism” thus it should have a common interface between group nodes and the leaf node (i.e., AnimationEffect
)Note that the proposed GroupEffect
is for Web Animation. The following
examples are implemented using AnimationWorklet just to showcase how
GroupEffect
should be used.
// Inside AnimationWorkletGlobalScope.
<script id="passthrough" type="text/worklet">
registerAnimator("test_animator", class {
animate(currentTime, effect) {
let effects = effect.getChildren();
for (var i = 0; i < effects.length; ++i)
effects[i].localTime = currentTime;
}
});
</script>
// In document scope.
// For simplicity, all the single keyframe effects are the same.
function SingleEffect(target) {
return new KeyframeEffect(target,
[
{ transform: 'scale(1.3) translateX(-400px) translateY(300px) rotate(360deg)',
opacity: 1 }
], {
duration: 500,
fill: 'forwards'
}
);
}
function AnotherEffect(target) {
return new KeyframeEffect(target,
[
{ transform: 'scale(1) translateX(-400px) translateY(-300px) rotate(0deg)',
opacity: 0 }
], {
duration: 500,
delay: 500,
fill: 'forwards'
}
);
}
// Converts the input text to multiple effects. Each effect is defined by the
// same KeyframeEffect function.
function Effects(text) {
var effects = [];
var wrapper = document.getElementById('wrapper');
text.split('').forEach(function(c) {
var div = document.createElement('div');
div.textContent = c;
div.setAttribute('class', 'box');
wrapper.appendChild(div);
effects.push(SingleEffect(div));
});
var children = wrapper.children;
for (var i = 0; i< children.length; ++i) {
effects.push(AnotherEffect(children[i]));
}
return effects;
}
function runInAnimationWorklet(code) {
return CSS.animationWorklet.addModule(
URL.createObjectURL(new Blob([code], {type: 'text/javascript'}))
);
}
runInAnimationWorklet(
document.getElementById('passthrough').textContent
).then(()=>{
var stagger_options = {
delay: 0.2, // time gap between two KeyframeEffects
emanation: 0, // index of emanative KeyframeEffect
timing: 'linear', // function that distributes the start time of each KeyframeEffect
};
var stagger_effect =
new StaggerEffect(Effects('stagger effect'),
{delay: 1000},
stagger_options);
var animation = new WorkletAnimation('test_animator', stagger_effect);
animation.play();
});
// Inside AnimationWorkletGlobalScope.
registerAnimator("test_animator", class {
constructor(options, state = { hold_time : 0 }) {
this.hold_time = state.hold_time;
}
animate(currentTime, effect) {
let effects = effect.getChildren();
if (currentTime <= effects[0].getTiming().duration * 0.3) {
effects[0].localTime = currentTime;
effects[1].localTime = 0;
this.hold_time = effects[0].localTime;
} else if (currentTime <= effects[0].getTiming().duration * 0.6) {
effects[0].localTime = this.hold_time;
effects[1].localTime = currentTime - this.hold_time;
} else {
effects[0].localTime = currentTime - this.hold_time;
effects[1].localTime = currentTime - this.hold_time;
}
}
state() {
return {
hold_time : this.hold_time
};
}
});
// In document scope.
let effect_a = new KeyframeEffect(
target,
[{ height: '100px' }, { height: '200px' }],
{ duration: 2000 }
);
let effect_b = new KeyframeEffect(
target,
[{width: '100px' }, {width: '200px' }],
{ duration: 2000 }
);
var exclusive_effect = new ParallelEffect([effect_a, effect_b], {fill: 'forwards'});
let animation = new WorkletAnimation('test_animator', exclusive_effect);
animation.play();