Timeline and steps
The engine compiles your timeline array into a pure chronology: at
any given time t (ms), the visual state is determined by an
evaluate(timeline, t) function. Backward seeking, step-by-step navigation,
and SSR come for free from this design.
Logical steps
Each root action (at the first level of timeline) constitutes a
logical step. The "Previous" / "Next" buttons of the player
navigate from one step to another.
A short pause (STEP_GAP) separates two consecutive steps: the "Next"
stop thus shows the "settled" step on its own, without overlapping
the appearance of the next one.
In the example below, the five root actions form five steps: use "Previous" / "Next" to step forward.
timeline: [
{ type: 'comment', object: 'browser', text: '1. …' }, // step 1
{ type: 'move', object: 'req', from: 'browser', to: 'api' }, // step 2
{ type: 'loading', id: 'work', object: 'api' }, // step 3
{ type: 'move', object: 'res', from: 'api', to: 'browser', wait_for: 'work' }, // step 4
{ type: 'comment', object: 'browser', text: '2. …', keep_until_end: true }, // step 5
],
Breakpoints
Actions produce breakpoints on which navigation can snap:
- A
moveproduces two points: when the object appears at its origin, then when it arrives at its destination. arrow,loading,set_content,comment,highlightproduce one point: their "settled" state at the end of the animation.
Synchronization between actions
Three mechanisms allow you to coordinate actions:
wait_for
The action starts at the end of another action referenced by its id:
{ type: 'loading', id: 'dbwork', object: 'db', duration: 900 },
{
type: 'move',
object: 'rows',
from: 'db',
to: 'api',
wait_for: 'dbwork', // starts when the loading finishes
},
parallel
Wraps multiple actions executed at the same time:
{
type: 'parallel',
actions: [
{ type: 'move', object: 'p1', from: 'a', to: 'b' },
{ type: 'move', object: 'p2', from: 'c', to: 'd' },
],
}
Here, a gateway broadcasts three requests to its services: the three packets depart simultaneously.
Custom durations
Each action accepts a duration (ms). The defaults are:
| Action type | Default (ms) |
|---|---|
comment, set_content | derived from the content — see below |
move | derived from the distance — see below |
arrow | 500 |
loading | 1200 |
highlight | 600 |
set_visible | 300 |
wait | 1000 |
Reading time
An action that carries something to read — a comment's text, a set_content's
panel — needs to stay on screen long enough for that text to actually be read.
Write no duration and the engine works one out from the content itself: a fixed
cost to notice what appeared, plus the length divided by a reading speed. Code is
given more time than prose, a table more than a label, and the result is bounded
so a two-word badge does not flash past and a long paragraph does not hold the
animation hostage.
// No duration: each bubble stays for as long as its own text needs.
{ type: 'comment', object: 'lb', text: 'Request 1 → Backend 1' },
{ type: 'comment', text: 'The load balancer distributes requests in turn, to balance the load.' },
The second bubble above is three times longer than the first, so it stays roughly three times as long — without anyone counting characters.
A comment's time is counted once the bubble is fully there: it fades in
first (over fade_in_ms, or 250 ms by default), and only then does the reading
time start. So duration: 3000 means three seconds of readable text, not three
seconds that include its own arrival.
An explicit duration always wins. It is a stated intent, not an estimate, so
reach for it whenever the content's length is not what should decide the timing —
a dramatic pause, a beat held for effect.
If the derived pace reads too fast or too slow for your audience, adjust it in one
place with pace on the spec rather than action by action:
const spec = {
pace: 1.25, // a quarter more reading time everywhere it was derived
nodes: [...],
packets: [...],
timeline: [...],
};
pace scales only the durations the engine derived. A duration you wrote
yourself is left exactly as written.
Reaching for wait right after a comment is usually a sign that the comment
needed more reading time. Remove both the wait and the comment's duration,
and let the length decide.
Travel time
The same idea applied to space: a move with no duration derives one from the
length of its trip, so two hops of different lengths are covered at the same
apparent speed instead of in the same time. The eye reads a speed, not a
duration — and a speed that changes for no reason reads as a mistake.
// No duration on either: the long hop simply takes longer.
{ type: 'move', object: 'rq', from: 'client', to: 'lb' },
{ type: 'move', object: 'rs', from: 'backend', to: 'client' },
A packet also waits at its origin long enough to be read. It carries text — a header, a query, a row count — and it meets the reader while still standing still, so that pause is at least as long as its content needs. It is counted once per packet: the next leg of the same route shows the same text.
Distances are measured in a fixed reference frame, never in the player's real pixels. That is what keeps one animation to one chronology: resize the player and the total duration, the step boundaries and the exported video all stay put.
Here too, an explicit duration wins. Reach for it when the pace of a movement
carries meaning the distance cannot — a deliberately slow hand-off, a burst meant
to feel abrupt.
Visual persistence
By default, an animated element remains visible for a short time after the end of
its animation (ARRIVE_HOLD) and then disappears. Three props extend this
presence:
keep_until
Remains visible until the start of an action targeted by id:
{ type: 'arrow', id: 'A', from: 'a', to: 'b', keep_until: 'C' },
{ type: 'move', object: 'p', from: 'a', to: 'b' },
{ type: 'comment', id: 'C', object: 'a', text: 'fin' },
// The arrow A remains displayed until the start of comment C.
keep_until_next
Remains visible until the start of the next root step (thus through the inter-step pause). Defaults by action type:
| Action type | keep_until_next default |
|---|---|
arrow, comment, set_content, highlight | true |
move, loading, set_visible | false |
keep_until_end
Remains visible until the end of the chronology:
{ type: 'highlight', object: 'db', keep_until_end: true },
Offsets and fades
Three fields refine the fine timing of an action, beyond its logical sequencing.
delay_ms
Delays the start by delay_ms milliseconds, after the resolution of
wait_for and snapping to the step. Its main use is to sequence
actions within a parallel ("cascade" effect):
{
type: 'parallel',
actions: [
{ type: 'move', object: 'p1', from: 'a', to: 'b' },
{ type: 'move', object: 'p2', from: 'a', to: 'b', delay_ms: 150 },
{ type: 'move', object: 'p3', from: 'a', to: 'b', delay_ms: 300 },
],
}
The same fan-out as above, but with an increasing delay_ms: the packets
stagger instead of departing as a block.
Applied to an entire parallel, it delays the whole group.
fade_in_ms / fade_out_ms
Control the duration of the fade-in and fade-out (in ms). 0
gives an instantaneous transition. fade_out_ms has no effect if
keep_until_end is true (the element never disappears).
{ type: 'comment', object: 'db', text: 'Lecture', fade_in_ms: 0, fade_out_ms: 600 }
Defaults: fade_out_ms → 250; fade_in_ms → 250 (300 for move).