Docs

Animation

Write animation as atTime(time, duration), a function of time. animate() is the fallback. Keep fontSize a positive constant.

View as Markdown

Write the animation of a custom element as a function of time. This is the recommended way. The timeline calls atTime(time, duration) for every frame it shows, and the method sets every animated property for that time.

import { clamp, easeOutCubic, map } from '@vidova/core';
 
/** Clip length when nothing gives one, such as a thumbnail. */
public readonly defaultDuration = 3;
 
public atTime(time: number, duration: number): void {
  const enter = easeOutCubic(clamp(0, 1, time / 0.4));
  const leave = easeOutCubic(clamp(0, 1, (time - (duration - 0.4)) / 0.4));
  this.opacity(enter * (1 - leave));
  this.y(map(40, 0, enter));
}
  • time is the seconds since the clip start, from 0 to duration.
  • duration is the clip length in seconds.
  • defaultDuration is the length used when there is no clip. Thumbnails pose the element at 65% of it.

Why atTime

The timeline can show any frame in any order: play, scrub, jump back. With atTime, each frame costs one call, and the same time always gives the same frame.

animate() is a thread. To show an earlier frame, the timeline makes a new instance and replays animate() from the clip start. Scrubbing back through a long clip gets slower the further in you are.

Rules for atTime

  1. Set every animated property on every call. A property you set only in some branches keeps the value from the last frame shown.
  2. Keep no state between calls. Do not count calls or keep the previous frame. Compute each value from time, duration and the inputs.
  3. Do not use unseeded random numbers or the wall clock. Use a seeded hash of an index for noise.
  4. Do not wait or measure. atTime cannot yield or await. Do not read width(), height() or glyphs() in it, because the font can still be loading. Read sizes in reactive props instead.

A schedule of steps

Give each step a start time and a length. clamp(0, 1, (time - start) / seconds) goes from 0 to 1 over the step. Pass it through an easing function, then map it to the values.

public atTime(time: number): void {
  const badge = this.badge();
  const title = this.title();
  // Badge pops in at 0.2 s for 0.3 s. The title slides in when the badge is done.
  badge.scale(easeOutBack(clamp(0, 1, (time - 0.2) / 0.3)));
  title.x(map(-200, 0, easeOutCubic(clamp(0, 1, (time - 0.5) / 0.6))));
}

A step that runs after another starts at the end of the one before it. Steps that run together share a start time.

One clock signal

When many properties depend on text layout, keep one clock signal and read it in reactive props. Set the clock from atTime:

private readonly clock = createSignal(0);
 
public constructor(props?: TickerProps) {
  super({ ...props });
  this.add(<Txt ref={this.label} x={() => this.xAt(this.clock())} text={() => this.text()} />);
}
 
public atTime(time: number): void {
  this.clock(time);
}

Reactive props update when the font loads, so they can read sizes.

Repeating motion

A loop is a function of time too. Use the remainder of a division, or a sine:

const phase = (time % 0.8) / 0.8;
this.dot().y(Math.sin(phase * Math.PI * 2) * -40);

fontSize stays positive

fontSize must be a positive number on every frame. Do not drive it from a value that starts at 0. Keep fontSize constant and animate scale.

this.scale(easeOutCubic(clamp(0, 1, time / 0.4)));

A fontSize of 0 compiles and then fails at render. The clip shows nothing.

Refs

Hold a child when atTime needs to set it:

import { createRef } from '@vidova/core';
import { Rect, Txt } from '@vidova/2d';
 
private readonly title = createRef<Txt>();
 
public constructor(props?: HelloTitleProps) {
  super({ ...props });
  this.add(
    <Rect>
      <Txt ref={this.title} text={() => this.label()} fontFamily="Inter Variable" />
    </Rect>,
  );
}
 
public atTime(time: number): void {
  this.title().opacity(easeOutCubic(clamp(0, 1, time / 0.4)));
}

Class fields for refs are fine. Class fields for @signal props must not collide with Node members. See Layout.

Fallback: animate()

Use animate() only when the motion cannot be a function of time. It is a generator. yield pauses until the next frame. yield* runs another generator to completion, such as a tween.

public *animateIn(duration: number = 0.4): ThreadGenerator {
  this.opacity(0);
  yield* this.opacity(1, duration, easeOutCubic);
}
 
public *animate(duration?: number): ThreadGenerator {
  yield* this.animateIn(duration ?? 0.4);
}
  • animate() starts on the first frame of the clip. A tween with no timing function uses easeInOutCubic.
  • Keep animateIn() separate. A thumbnail of an element without atTime plays animateIn() before capture.
  • Do not yield a promise such as fetch(...). The timeline stops the element and logs an error. yield node is allowed. It waits for the fonts and images of that node.
  • The same frame must come out the same on every replay. Keep state in the instance, never in module variables.