Signals
One writable value, and the five verbs that change it — set, tween, play, animate and transition.
class Badge extends LayoutActor {
label = signal("Ready");
tone = signal<"neutral" | "success">("neutral");
}A signal is a function you call to read, with methods that write. Reading is
this.label(); writing is set, or one of the motion verbs below.
Use it for
- Flat writable state the timeline owns.
- Canonical fields like
layout.xandappearance.opacity— everyLayoutActorgroup is built from signals. - Anything where the value's history is authored rather than observed.
Reading and writing
badge.label(); // read — resolves against the current frame
badge.label.set("Done"); // immediate write at the authoring cursor
yield* badge.appearance.opacity.tween(0, 0.3); // consumes 0.3s of authored timeset takes no authored time; it writes at whatever cursor position the generator has
reached. tween records a range.
Typing
The initial value infers the type. Widen explicitly when the initial value is narrower than the domain:
tone = signal<"neutral" | "success">("neutral"); // union, not "neutral"
items = signal<DockItem[]>([]); // element type, not never[]Motion
Every signal supports the same motion surface. The verbs differ in one axis that matters more than any other: whether they consume authored time.
The three rows are the three animated verbs, compiled and running. tween moves once
and blocks. play repeats the same range, alternating direction. animate does not
block, so the scale pulse and the tween on the line after it start at the same cursor.
| Verb | Consumes authored time | Use for |
|---|---|---|
set | no | An immediate write at the current cursor |
tween | yes (yield*) | The default animation |
play | yes (yield*) | Repeats, alternation, fill behaviour |
animate | no | Fire-and-forget motion alongside other work |
transition | yes (yield*) | Custom or multi-field interpolation |
set
actor.appearance.opacity.set(0);Writes immediately. No yield*, no time consumed — the value simply is different from
this cursor position onward.
tween
The default. The second argument is a duration in seconds, or a SignalTweenOptions
object.
// seconds shorthand
yield* actor.appearance.opacity.tween(0, 0.3);
// easing
yield* actor.layout.x.tween(200, {
duration: 0.4,
easing: outCubic,
});
// spring physics — duration is derived from the spring, not given
yield* actor.layout.y.tween(400, {
spring: { stiffness: 300, damping: 20 },
});play
tween with iteration and direction control.
yield* actor.transform.scale.play({
to: 1.2,
duration: 0.3,
direction: "alternate",
iterations: 4,
fill: "forwards",
});animate
play that does not block. The generator continues immediately while the motion runs
alongside it — the tool for ambient motion that should not gate the timeline.
actor.transform.scale.animate({ to: 1.05, duration: 0.2 });
// Named, so it can be cancelled by name later
actor.transform.scale.animate("hover", { to: 1.05, duration: 0.2 });animate does not move the cursor
Because animate takes no authored time, the next yield* in your generator starts at
the same cursor position. Two animate calls in a row overlap; two tween calls in a
row are sequential.
transition
Interpolates from the starting value using a function of progress. This is the escape hatch for anything the scalar verbs cannot express — non-numeric values, several fields that must move as one, easing applied to a whole structure.
yield* actor.layout.x.transition(
(progress, from) => from + (target - from) * outCubic(progress),
0.4,
);For a signal<T[]> it is how a whole collection animates while staying one logical write:
yield* this.windows.transition(
(progress) =>
baseline.map((window) =>
window.id === windowId
? animateWindowField(window, progress, { openProgress: 1 })
: window,
),
{ duration, easing: Easing.out(Easing.inCubic) },
);Durations
type Duration =
| number // seconds
| { frames: number }; // explicit framesAuthor in seconds. Use { frames: n } only when the duration is genuinely
frame-quantized — a single-frame flash, a sprite step — because a scene authored in
frames breaks when the fps changes.
Composing
Concurrency comes from the runtime helpers, not from the verbs:
// Together
yield* this.runtime.all(
this.layout.x.tween(200, 0.4),
this.layout.y.tween(100, 0.4),
);
// Staggered
yield* this.runtime.sequence(0.05, ...items.map((i) => i.enter()));See scene orchestration for the full set.
Do not use it for
| Instead of | Use |
|---|---|
| Computed state | derived |
| Nested state with independently animated leaves | reactiveSignal |
| Values a host measures or publishes | channel |
| Cross-actor coordination | events and subscribe |
A collection can still be one signal
An array that always moves as one logical change — a window list, a set of dock items —
is a single signal<T[]>, animated with transition. Reach for reactiveSignal only
when individual leaves need to animate independently of each other.