---
title: Writing a backend
description: Build a backend for another drawing surface, with a host, renderers and an effect kind of its own.
sidebar:
  hidden: true
mode: wide
---

A backend is three values: a ref that scenes import, a host component that mounts a drawing
surface, and the effect kinds the surface can paint. This section builds one for SVG. Each actor
becomes a `<g>`, SVG filters become effects, and the stock scene machinery does the rest:
timing, slots, seeking, capture.

<SvgDotsScene />

Every circle above is an SVG element drawn by the host on this page. Each one carries an effect
of the same kind: a blur, a halo and a wobble.

| Page | |
| --- | --- |
| [Effect kinds](/docs/rendering/writing-a-backend/effects) | The SVG filter kind, its stage, and the three effects in the figure |
| [Holding a frame](/docs/rendering/writing-a-backend/frames) | Drawing that waits for a texture or a font, and surfaces with their own React tree |

## The ref

The ref names the backend. It is plain data with no React in it, so a scene file, a renderer
package or an effect can name the backend without pulling the host into its module graph:

```ts twoslash title="svg.ts"
// @filename: svg.ts
// ---cut---
import { defineBackend } from "@motionactor/core";

export const svg = defineBackend("svg");
```

## The host

The host is a React component the scene renderer mounts once per layer that uses the
backend. It gets the frame's actors and draws them:

```tsx twoslash title="svg-host.tsx"
// @jsx: react-jsx
// @filename: svg.ts
import { defineBackend } from "@motionactor/core";

export const svg = defineBackend("svg");
// @filename: svg-filter.tsx
import { useId, type ReactNode } from "react";
import {
  defineEffectKind,
  effectKindTypes,
  readEffectDefinition,
  resolveEffectParams,
  type EffectKindTypes,
  type EffectStage,
} from "@motionactor/core";
import { svg } from "./svg";

/** One SVG filter primitive, as data. */
export type SvgPrimitive =
  | {
      readonly type: "feGaussianBlur";
      readonly stdDeviation: number;
      readonly result?: string;
    }
  | {
      readonly type: "feTurbulence";
      readonly baseFrequency: number;
      readonly numOctaves: number;
      readonly result: string;
    }
  | {
      readonly type: "feDisplacementMap";
      readonly in: string;
      readonly in2: string;
      readonly scale: number;
    }
  | { readonly type: "feMerge"; readonly inputs: readonly string[] };

/** The primitives of one filter, from an effect's params. */
export type SvgPrimitives<P> = (params: P) => readonly SvgPrimitive[];

/** What an `svgFilter` effect supplies beside its params. */
export interface SvgFilterSpec<P> {
  readonly primitives: SvgPrimitives<P>;
}

export interface SvgFilterTypes extends EffectKindTypes {
  readonly spec: SvgFilterSpec<this["params"]>;
  readonly runtime: SvgPrimitives<this["params"]>;
}

const element = (primitive: SvgPrimitive, key: number) => {
  switch (primitive.type) {
    case "feGaussianBlur":
      return (
        <feGaussianBlur
          key={key}
          stdDeviation={primitive.stdDeviation}
          result={primitive.result}
        />
      );
    case "feTurbulence":
      return (
        <feTurbulence
          key={key}
          baseFrequency={primitive.baseFrequency}
          numOctaves={primitive.numOctaves}
          result={primitive.result}
        />
      );
    case "feDisplacementMap":
      return (
        <feDisplacementMap
          key={key}
          in={primitive.in}
          in2={primitive.in2}
          scale={primitive.scale}
        />
      );
    case "feMerge":
      return (
        <feMerge key={key}>
          {primitive.inputs.map((input, i) => (
            <feMergeNode key={i} in={input} />
          ))}
        </feMerge>
      );
  }
};

function Filtered(props: {
  primitives: readonly SvgPrimitive[];
  children: ReactNode;
}) {
  const id = useId();
  return (
    <>
      <filter id={id} x="-50%" y="-50%" width="200%" height="200%">
        {props.primitives.map(element)}
      </filter>
      <g filter={`url(#${id})`}>{props.children}</g>
    </>
  );
}

/** Each effect gets its own filter, wrapped around the ones before it. */
export const svgFilterStage: EffectStage<ReactNode> = {
  wrap: ({ effects }, node) =>
    effects.reduce((inner, effect) => {
      const definition = readEffectDefinition(effect.binding);
      const primitives = definition && svgFilter.runtimeOf(definition);
      if (!primitives) return inner;
      return (
        <Filtered primitives={primitives(resolveEffectParams(effect))}>
          {inner}
        </Filtered>
      );
    }, node),
};

export const svgFilter = defineEffectKind(svg, {
  name: "filter",
  types: effectKindTypes<SvgFilterTypes>(),
  applies: "actor",
  lower: (spec) => spec.primitives,
});
// @filename: svg-host.tsx
// ---cut---
import { useCallback, type ReactNode } from "react";
import {
  assertSubtreeCompositingSupported,
  type ResolvedActor,
} from "@motionactor/core";
import {
  createBackend,
  type BackendHostProps,
  groupEffectsByStage,
  ActorRendererOutlet,
  FrameScope,
} from "@motionactor/renderers/react";
import { svg as svgRef } from "./svg";
import { svgFilter, svgFilterStage } from "./svg-filter";

function SvgHost(props: BackendHostProps<ReactNode>) {
  const { actors, state, compiled, registry, width, height, effectStages } = props;
  assertSubtreeCompositingSupported(actors, state.actors, svgRef);

  // One function while the stages hold, so outlets skip actors that did not change.
  const place = useCallback((actor: ResolvedActor, content: ReactNode) => {
    const { x, y, width, height } = actor.localLayout;
    let node = content;
    for (const run of groupEffectsByStage(actor.effects, effectStages)) {
      const context = { actor, effects: run.effects, box: { width, height } };
      node = run.stage.stage.wrap(context, node);
    }
    return (
      <g
        data-actor={actor.id}
        transform={`translate(${x} ${y})`}
        opacity={actor.compositing.opacity}
      >
        {node}
      </g>
    );
  }, [effectStages]);

  return (
    <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
      <FrameScope state={state}>
        {actors.map((actor) => (
          <ActorRendererOutlet
            key={actor.id}
            id={actor.id}
            compiled={compiled}
            registry={registry}
            wrapActor={place}
          />
        ))}
      </FrameScope>
    </svg>
  );
}

// The backend under the ref's name, so renderer files import one `svg`.
export const svg = createBackend(svgRef, {
  host: SvgHost,
  stages: [{ kinds: [svgFilter], stage: svgFilterStage }],
});
```

`FrameScope` holds the frame the host paints. Each `ActorRendererOutlet` reads its actor from it
by id, finds the renderer for the actor, draws it, and draws the actors hosted in its slots
through the same outlet. An outlet draws again only when its actor changed, or when what its
renderer read did (`frame`, `state`, or a slot entry's `actor`), so `wrapActor` should be one
function across frames. `wrapActor` runs for each of them, so a slot child gets its
own `<g>` inside its parent's. A child's `localLayout` is relative to its parent, which is how
nested SVG transforms compose.

`stages` pairs each stage with the effect kinds it paints: a kind names no stage, so each
binding's backend supplies its own. The runtime orders them into `effectStages`, innermost first, and `groupEffectsByStage` hands each stage the actor's effects
it owns. `place` lets every stage wrap the actor's content in turn. The host knows nothing about
filters: [Effect kinds](/docs/rendering/writing-a-backend/effects) builds `svgFilter` and its stage.

| `BackendHostProps` | |
| --- | --- |
| `actors` | The layer's painted roots, in paint order. Slot children are reached through them |
| `state` | The whole frame: every actor by id, layers, camera |
| `frame`, `compiled` | The frame number and the compiled scene |
| `registry` | The renderers this backend can draw with |
| `effectStages` | The backend's effect stages, innermost first |
| `width`, `height` | The layer's size in pixels |
| `layer` | The id of the layer it paints, the one its actors carry; `null` for the default layer |

Opacity maps to an attribute. Blend modes, isolation and clipping need an offscreen group this
host does not build, so it calls `assertSubtreeCompositingSupported` first. An actor that asks
for one fails the frame with an error naming the actor, the backend and what it asked for,
instead of drawing without it.

## Renderers

Renderers for the backend come from its `render`, as on the built-in backends. The row
draws nothing of its own; its slot already holds an element for each dot:

```tsx twoslash title="svg-dots.render.tsx"
// @jsx: react-jsx
// @filename: svg.ts
import { defineBackend } from "@motionactor/core";

export const svg = defineBackend("svg");
// @filename: svg-filter.tsx
import { useId, type ReactNode } from "react";
import {
  defineEffectKind,
  effectKindTypes,
  readEffectDefinition,
  resolveEffectParams,
  type EffectKindTypes,
  type EffectStage,
} from "@motionactor/core";
import { svg } from "./svg";

/** One SVG filter primitive, as data. */
export type SvgPrimitive =
  | {
      readonly type: "feGaussianBlur";
      readonly stdDeviation: number;
      readonly result?: string;
    }
  | {
      readonly type: "feTurbulence";
      readonly baseFrequency: number;
      readonly numOctaves: number;
      readonly result: string;
    }
  | {
      readonly type: "feDisplacementMap";
      readonly in: string;
      readonly in2: string;
      readonly scale: number;
    }
  | { readonly type: "feMerge"; readonly inputs: readonly string[] };

/** The primitives of one filter, from an effect's params. */
export type SvgPrimitives<P> = (params: P) => readonly SvgPrimitive[];

/** What an `svgFilter` effect supplies beside its params. */
export interface SvgFilterSpec<P> {
  readonly primitives: SvgPrimitives<P>;
}

export interface SvgFilterTypes extends EffectKindTypes {
  readonly spec: SvgFilterSpec<this["params"]>;
  readonly runtime: SvgPrimitives<this["params"]>;
}

const element = (primitive: SvgPrimitive, key: number) => {
  switch (primitive.type) {
    case "feGaussianBlur":
      return (
        <feGaussianBlur
          key={key}
          stdDeviation={primitive.stdDeviation}
          result={primitive.result}
        />
      );
    case "feTurbulence":
      return (
        <feTurbulence
          key={key}
          baseFrequency={primitive.baseFrequency}
          numOctaves={primitive.numOctaves}
          result={primitive.result}
        />
      );
    case "feDisplacementMap":
      return (
        <feDisplacementMap
          key={key}
          in={primitive.in}
          in2={primitive.in2}
          scale={primitive.scale}
        />
      );
    case "feMerge":
      return (
        <feMerge key={key}>
          {primitive.inputs.map((input, i) => (
            <feMergeNode key={i} in={input} />
          ))}
        </feMerge>
      );
  }
};

function Filtered(props: {
  primitives: readonly SvgPrimitive[];
  children: ReactNode;
}) {
  const id = useId();
  return (
    <>
      <filter id={id} x="-50%" y="-50%" width="200%" height="200%">
        {props.primitives.map(element)}
      </filter>
      <g filter={`url(#${id})`}>{props.children}</g>
    </>
  );
}

/** Each effect gets its own filter, wrapped around the ones before it. */
export const svgFilterStage: EffectStage<ReactNode> = {
  wrap: ({ effects }, node) =>
    effects.reduce((inner, effect) => {
      const definition = readEffectDefinition(effect.binding);
      const primitives = definition && svgFilter.runtimeOf(definition);
      if (!primitives) return inner;
      return (
        <Filtered primitives={primitives(resolveEffectParams(effect))}>
          {inner}
        </Filtered>
      );
    }, node),
};

export const svgFilter = defineEffectKind(svg, {
  name: "filter",
  types: effectKindTypes<SvgFilterTypes>(),
  applies: "actor",
  lower: (spec) => spec.primitives,
});
// @filename: svg-host.tsx
import { useCallback, type ReactNode } from "react";
import {
  assertSubtreeCompositingSupported,
  type ResolvedActor,
} from "@motionactor/core";
import {
  createBackend,
  type BackendHostProps,
  groupEffectsByStage,
  ActorRendererOutlet,
  FrameScope,
} from "@motionactor/renderers/react";
import { svg as svgRef } from "./svg";
import { svgFilter, svgFilterStage } from "./svg-filter";

function SvgHost(props: BackendHostProps<ReactNode>) {
  const { actors, state, compiled, registry, width, height, effectStages } = props;
  assertSubtreeCompositingSupported(actors, state.actors, svgRef);

  // One function while the stages hold, so outlets skip actors that did not change.
  const place = useCallback((actor: ResolvedActor, content: ReactNode) => {
    const { x, y, width, height } = actor.localLayout;
    let node = content;
    for (const run of groupEffectsByStage(actor.effects, effectStages)) {
      const context = { actor, effects: run.effects, box: { width, height } };
      node = run.stage.stage.wrap(context, node);
    }
    return (
      <g
        data-actor={actor.id}
        transform={`translate(${x} ${y})`}
        opacity={actor.compositing.opacity}
      >
        {node}
      </g>
    );
  }, [effectStages]);

  return (
    <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
      <FrameScope state={state}>
        {actors.map((actor) => (
          <ActorRendererOutlet
            key={actor.id}
            id={actor.id}
            compiled={compiled}
            registry={registry}
            wrapActor={place}
          />
        ))}
      </FrameScope>
    </svg>
  );
}

// The backend under the ref's name, so renderer files import one `svg`.
export const svg = createBackend(svgRef, {
  host: SvgHost,
  stages: [{ kinds: [svgFilter], stage: svgFilterStage }],
});
// @filename: svg-dots.scene.ts
import {
  createScene,
  defineEffect,
  Easing,
  LayoutActor,
  registerEffects,
  signal,
  slot,
} from "@motionactor/core";
import { svgFilter } from "./svg-filter";

export class Dot extends LayoutActor {
  fill = signal("#f86e42");
}

export class Row extends LayoutActor {
  dots = slot({ accepts: Dot, multiple: true });
}

export const blur = defineEffect(svgFilter, {
  name: "blur",
  params: { radius: signal(14), progress: signal(0) },
  primitives: ({ radius, progress }) => [
    { type: "feGaussianBlur", stdDeviation: radius * progress },
  ],
});

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

export const halo = defineEffect(svgFilter, {
  name: "halo",
  params: { spread: signal(18), progress: signal(0) },
  primitives: ({ spread, progress }) => [
    {
      type: "feGaussianBlur",
      stdDeviation: spread * progress,
      result: "halo",
    },
    { type: "feMerge", inputs: ["halo", "halo", "SourceGraphic"] },
  ],
});

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

export const wobble = defineEffect(svgFilter, {
  name: "wobble",
  params: { scale: signal(48), progress: signal(0) },
  primitives: ({ scale, progress }) => [
    {
      type: "feTurbulence",
      baseFrequency: 0.02,
      numOctaves: 1,
      result: "noise",
    },
    {
      type: "feDisplacementMap",
      in: "SourceGraphic",
      in2: "noise",
      scale: scale * progress,
    },
  ],
});

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

export const scene = createScene(function* (ctx) {
  const row = ctx.spawn(Row, {
    layout: { x: 200, y: 220, width: 880, height: 120 },
  });
  const [coral, amber, violet] = ["#f86e42", "#f5a524", "#9b8cff"].map(
    (fill, i) =>
      row.dots.spawn(Dot, {
        fill,
        layout: { x: i * 380, y: 0, width: 120, height: 120 },
      }),
  );
  const filters = [
    coral.fx.blur(),
    amber.fx.halo(),
    violet.fx.wobble(),
  ];

  yield* ctx.wait(0.3);
  yield* ctx.sequence(
    0.2,
    ...filters.map(function* (filter) {
      yield* filter.play({ duration: 0.5, easing: Easing.outCubic });
      yield* ctx.wait(0.3);
      yield* filter.progress.tween(0, {
        duration: 0.5,
        easing: Easing.inCubic,
      });
    }),
  );
  yield* ctx.wait(0.5);

  return { row };
});
// @filename: svg-dots.render.tsx
// ---cut---
import { createRenderRuntime } from "@motionactor/renderers/react";
import { svg } from "./svg-host";
import { Dot, Row } from "./svg-dots.scene";

const dot = svg.render(Dot, ({ actor }) => {
  const { width, height } = actor.localLayout;
  return (
    <circle
      cx={width / 2}
      cy={height / 2}
      r={Math.min(width, height) / 2}
      fill={actor.signals.fill}
    />
  );
});

// The row draws nothing itself; each hosted dot is placed by the host.
const row = svg.render(Row, ({ slots }) => (
  <>{slots.dots.map(({ element }) => element)}</>
));

export const runtime = createRenderRuntime({
  backends: [svg],
  renderers: [row, dot],
});
```

The scene in the figure is a stock scene. With one backend in the runtime, the default layer
uses it.
