Skip to content
MotionActor
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.

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 });

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

Was this page helpful?