---
title: Effects
mode: wide
description: An effect is a timed record on an actor that the actor's backend paints, with typed params a scene can tween.
---

An effect changes how an actor looks without changing the actor. Applying one records it on
the timeline next to the actor, with its own params, and the backend that paints the actor
reads the record and draws the treatment. The actor's own state is never touched, so removing
the effect leaves nothing to undo.

<EffectGlowScene />

The card's renderer draws a plain panel. The glow comes from the DOM backend's style stage,
which reads `card.effects` on each frame and adds a CSS filter around the card. `play` fades
the glow in, a tween on `spread` widens it, and `detach` ends it.

## Define

`defineEffect(kind, { name, params, ...contribution })` declares an effect once. The kind
comes from the backend package and fixes three things: which backend paints the effect, which
stage paints it, and what the contribution is. Params are signals, and the contribution is
typed from them, so no separate params type exists to keep in sync.

```ts twoslash title="describe.ts"
// @filename: effect-glow.scene.ts
import {
  color,
  colorToHex,
  colorType,
  createScene,
  defineEffect,
  Easing,
  LayoutActor,
  registerEffects,
  signal,
} from "@motionactor/core";
import { domStyle } from "@motionactor/dom";

export class Card extends LayoutActor {}

export const glow = defineEffect(domStyle, {
  name: "glow",
  params: {
    color: signal.of(colorType, color("#f86e42")),
    spread: signal(16),
    progress: signal(0),
  },
  style: ({ color, spread, progress }) => {
    const px = spread * progress;
    const hex = colorToHex(color);
    const filter = `drop-shadow(0 0 ${px}px ${hex})`;
    return { filter };
  },
});

declare module "@motionactor/core" {
  interface ActorEffects {
    "dom:glow": typeof glow;
  }
}
registerEffects([glow]);

export const scene = createScene(function* (ctx) {
  const card = ctx.spawn(Card, {
    layout: { x: 380, y: 140, width: 520, height: 280 },
  });
  yield* ctx.wait(0.3);

  const glowing = card.fx.glow();
  yield* glowing.play({
    duration: 0.6,
    easing: Easing.outCubic,
  });
  yield* glowing.spread.tween(48, 0.5);
  yield* ctx.wait(0.3);

  glowing.detach();
  yield* ctx.wait(0.6);

  return { card };
});
// @filename: describe.ts
// ---cut---
import { describeEffect } from "@motionactor/core";
import { glow } from "./effect-glow.scene";

glow.name; // "glow"
describeEffect(glow).params.spread;
// { kind: "number", default: 16 }
```

`describeEffect` is what an editor reads to build a control for each param: its kind, its
declared type, its range and its default.

| Kind | Import | Contribution | Painted by |
| --- | --- | --- | --- |
| `domStyle` | `@motionactor/dom` | `style(params, { box }) → { filter, translateX, rotate, scale, opacity, clipPath, overlays, … }` | The DOM style stage, inline |
| `glShader` | `@motionactor/dom` | A WebGL `shader` with `uniforms(params, frame)` | The DOM canvas stage, offscreen |
| `canvasPaint` | `@motionactor/dom` | `paint(context2d, ctx)` | The DOM canvas stage, offscreen |
| `pixiFilter` | `@motionactor/pixi` | A Pixi `filter` class and `update(filter, params)` | The Pixi filter stage |
| `threePass` | `@motionactor/three` | A postprocessing `pass` and `update(pass, params)` | The Three.js host's composer |
| `threeMaterial` | `@motionactor/three` | `material: { apply, update, remove }` | The Three.js host |

Several `domStyle` effects on one actor fold together: filters append, translations and
rotations add.

### Overlays

A `domStyle` effect draws over its actor with `overlays`. They're data built with `overlay.*`,
so the same effect paints under React, Vue and Svelte:

```ts twoslash title="ring.ts"
import { defineEffect, signal } from "@motionactor/core";
import { domStyle, overlay } from "@motionactor/dom";

export const ring = defineEffect(domStyle, {
  name: "ring",
  params: { width: signal(2), progress: signal(0) },
  style: ({ width, progress }, { box }) => ({
    overlays: [
      overlay.svg(
        { style: { position: "absolute", inset: 0 }, attrs: { viewBox: `0 0 ${box?.width ?? 0} ${box?.height ?? 0}` } },
        [overlay.rect({ attrs: { x: 1, y: 1, width: (box?.width ?? 2) - 2, height: (box?.height ?? 2) - 2, fill: "none", stroke: "#f86e42", strokeWidth: width, opacity: progress } })],
      ),
    ],
  }),
});
```

A painter that draws imperatively, such as a WebGL shader, attaches to an overlay element with
`overlayMount(id, props, painter)`. The painter starts once and receives each later frame's props
while the id holds. It is disposed when the element goes. Declare the painter at module level:
a mount with a different painter restarts.

## Install

The scene above installs `glow` beside its definition, in two statements. The `ActorEffects`
entry, keyed `"<backend>:<name>"`, types the name. `registerEffects([glow])` makes it
callable. Importing the module installs the effect. A type-only import runs no registration, so
the call throws, naming the effect.

- A later registration at the same backend and name replaces the earlier one. A hot reload or a
  second module loader re-registers without error.
- Two `ActorEffects` entries for one key with different definitions are a type error.
- `registerEffects` throws for an actor effect named `for`.
- An effect that cannot apply to an actor, a layer effect or one that needs target data,
  registers too. It resolves by wire id but stays off `fx`; apply it to a target with
  `ctx.apply`.

A module imported only for its registration is a side effect. Under `"sideEffects": false` a
bundler drops it, and the call throws. A package that ships effect modules, or wraps copied
ones, lists them and the entries that import them:

```json title="package.json"
{ "sideEffects": ["./src/**/effect.ts", "./dist/index.mjs", "./dist/index.cjs"] }
```

## Call by name

`fx` is a getter on `Actor`, so every actor, child field and slot occupant has the same menu:
each installed effect whose name one backend defines. Params come first, playback second.

```ts
const glowing = card.fx.glow(); // handle, default params
const wide = card.fx.glow({ spread: 24 }); // handle
yield* card.fx.glow({ duration: 0.6 }); // played, default params
yield* card.fx.glow({ spread: 24 }, { duration: 0.6, easing: Easing.outCubic }); // played
```

- Without playback, the effect applies at the cursor and the call returns its handle.
- With playback, it applies when the generator first runs, then plays to its end and holds
  there. Under `ctx.delay` that is when the delay ends.
- One object with `duration` is playback, so no effect param may be named `duration` or
  `easing`.
- An effect with a required param has no `()` or `(playback)` form.

On the handle:

- `handle.play({ duration, easing? })` tweens a param named `progress` from 0 to 1. An effect
  without a `progress` param just waits out the duration.
- Every param is a signal, so `handle.spread.tween(48, 0.5)` and `handle.color.set(...)` work
  as they do on an actor. A param given as a `derived` has no signal on the handle.
- `handle.detach()` ends the record at the cursor. Frames before it still show the effect.

### One name on two backends

A name two installed backends define leaves the menu: `card.fx.glow` is a type error, and at
runtime the call throws, naming both. `fx.for(backend)` selects one:

```ts twoslash title="clash.ts"
import { createScene, defineEffect, LayoutActor, registerEffects, signal } from "@motionactor/core";
import { dom, domStyle } from "@motionactor/dom";
import { pixi, pixiFilter } from "@motionactor/pixi";
import { BlurFilter } from "pixi.js";

const domGlow = defineEffect(domStyle, {
  name: "glow",
  params: { spread: signal(16) },
  style: ({ spread }) => ({ filter: `drop-shadow(0 0 ${spread}px #f86e42)` }),
});

const pixiGlow = defineEffect(pixiFilter, {
  name: "glow",
  params: { strength: signal(8) },
  filter: BlurFilter,
  update: (filter, { strength }) => {
    filter.strength = strength;
  },
});

declare module "@motionactor/core" {
  interface ActorEffects {
    "dom:glow": typeof domGlow;
    "pixi:glow": typeof pixiGlow;
  }
}
registerEffects([domGlow, pixiGlow]);

export const compiled = createScene(function* (ctx) {
  const card = ctx.spawn(LayoutActor);
  // @ts-expect-error: dom and pixi both define glow
  card.fx.glow();
  yield* card.fx.for(dom).glow({ spread: 24 }, { duration: 0.4 });
}).build({ viewport: { width: 1920, height: 1080 }, fps: 30 });
```

### Checked when it paints

TypeScript does not know which backend paints an actor: `layer(dom).spawn(Card)` has the same
menu as any actor. In every build, an effect for another backend throws before it paints,
naming the effect, the actor and both backends. On an actor in a declared layer, the call throws
as the scene compiles. Selecting with `for(backend)` does not skip the check.

```txt
Effect "glow" on card "card" is for backend "pixi", but the default layer is painted by "dom"
```

An offscreen effect (`glShader`, `canvasPaint`) applies to a child a view binds only when its
target contract clips, `clips: true`, so it paints inside the child's rect and nowhere else.
The binding refuses any other with a `BoundElementError`, in every build, including one that
starts later. [Bindings](/docs/rendering/renderers#offscreen-effects) shows one.

### Explicit targets

`ctx.apply(target, effect(params?))` is the primitive under `fx`, and returns the same handle.
A scene uses it for what is not an actor, such as a target factory's text range or mask, and
for a definition picked at runtime, such as one from an editor palette:

```ts
const highlight = ctx.apply(article.targets.range("lead"), textHighlight());
yield* highlight.play({ duration: 0.4 });
```

## What a frame reports

`actor.effects` lists the effects active at the frame, in the order they were applied:

```ts twoslash
// @filename: effect-glow.scene.ts
import {
  color,
  colorToHex,
  colorType,
  createScene,
  defineEffect,
  Easing,
  LayoutActor,
  registerEffects,
  signal,
} from "@motionactor/core";
import { domStyle } from "@motionactor/dom";

export class Card extends LayoutActor {}

export const glow = defineEffect(domStyle, {
  name: "glow",
  params: {
    color: signal.of(colorType, color("#f86e42")),
    spread: signal(16),
    progress: signal(0),
  },
  style: ({ color, spread, progress }) => {
    const px = spread * progress;
    const hex = colorToHex(color);
    const filter = `drop-shadow(0 0 ${px}px ${hex})`;
    return { filter };
  },
});

declare module "@motionactor/core" {
  interface ActorEffects {
    "dom:glow": typeof glow;
  }
}
registerEffects([glow]);

export const scene = createScene(function* (ctx) {
  const card = ctx.spawn(Card, {
    layout: { x: 380, y: 140, width: 520, height: 280 },
  });
  yield* ctx.wait(0.3);

  const glowing = card.fx.glow();
  yield* glowing.play({
    duration: 0.6,
    easing: Easing.outCubic,
  });
  yield* glowing.spread.tween(48, 0.5);
  yield* ctx.wait(0.3);

  glowing.detach();
  yield* ctx.wait(0.6);

  return { card };
});
// @filename: main.ts
// ---cut---
import { scene } from "./effect-glow.scene";

using compiled = scene.build({ viewport: { width: 1280, height: 720 }, fps: 30 });
const [fx] = compiled.at(45).scene.card!.effects;
fx?.effectType; // "glow"
fx?.params.spread; // 48
fx?.params.progress; // 1
fx?.progress; // 0.857, how far through the application frame 45 is

compiled.at(60).scene.card?.effects; // [], detached at frame 51
```

`fx.progress` measures the application itself, from the call to `detach`, and is 1 for an
effect that is never detached. The `progress` param is the one `play` drives, so a style can
fade in once and hold.

## Pixi and Three.js

The same pattern works on the canvas backends. A kind whose contribution names a class infers
the type of the native object it creates, so `update` gets a typed filter or pass:

```ts twoslash title="fx.ts"
import { defineEffect, signal } from "@motionactor/core";
import { pixiFilter } from "@motionactor/pixi";
import { threePass } from "@motionactor/three";
import { BlurFilter } from "pixi.js";
import { BloomEffect } from "postprocessing";

export const blur = defineEffect(pixiFilter, {
  name: "blur",
  params: { strength: signal(8) },
  filter: BlurFilter,
  update: (filter, { strength }) => {
    filter.strength = strength;
  },
});

export const bloom = defineEffect(threePass, {
  name: "bloom",
  params: { intensity: signal(1.5) },
  pass: BloomEffect,
  update: (pass, { intensity }) => {
    pass.intensity = intensity;
  },
});
```

## Effects by wire id

The record carries the definition of an effect the scene imported. An app that names effects
without their definitions, such as an editor palette or a process that receives
`{ backend: "dom", name: "glow" }` over IPC, imports their modules. A render runtime resolves
every registered effect of its own backends:

```tsx twoslash title="editor-runtime.ts"
// @filename: glow.ts
import { color, colorToHex, colorType, defineEffect, registerEffects, signal } from "@motionactor/core";
import { domStyle } from "@motionactor/dom";
export const glow = defineEffect(domStyle, {
  name: "glow",
  params: { color: signal.of(colorType, color("#f86e42")), spread: signal(16), progress: signal(0) },
  style: ({ color, spread, progress }) => ({ filter: `drop-shadow(0 0 ${spread * progress}px ${colorToHex(color)})` }),
});
declare module "@motionactor/core" {
  interface ActorEffects {
    "dom:glow": typeof glow;
  }
}
registerEffects([glow]);
// @filename: editor-runtime.ts
// ---cut---
import "./glow";
import { createRenderRuntime } from "@motionactor/renderers";
import { dom } from "@motionactor/dom/react";

export const runtime = createRenderRuntime({ backends: [dom] });

runtime.allAvailableEffects(); // [{ backend: "dom", name: "glow" }]
runtime.resolveEffectBinding({ backend: "dom", name: "glow" });
runtime.describeEffect({ backend: "dom", name: "glow" });
```

A runtime without the `dom` backend resolves none of them.
