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.x and appearance.opacity — every LayoutActor group 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 time

set 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.

frame 0·0.00stween.x 0.00 · play.x 0.00 · animate.scale 1.00

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.

VerbConsumes authored timeUse for
setnoAn immediate write at the current cursor
tweenyes (yield*)The default animation
playyes (yield*)Repeats, alternation, fill behaviour
animatenoFire-and-forget motion alongside other work
transitionyes (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 frames

Author 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 ofUse
Computed statederived
Nested state with independently animated leavesreactiveSignal
Values a host measures or publisheschannel
Cross-actor coordinationevents 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.

On this page