---
title: Vue
description: Paint scenes with Vue 3 through @motionactor/renderers/vue and @motionactor/dom/vue.
---

Scenes, actors and effects are the same under every binding. A Vue app changes two things:
its renderers return VNodes, and it mounts through the `/vue` entries.

| Package | Vue entry |
| --- | --- |
| `@motionactor/renderers` | `/vue`: `SceneRenderer`, renderers, composables, compositions, transitions, media |
| `@motionactor/dom` | `/vue`: `dom` (the backend), `DomHost`, `ActorElement`, `vStyle`, effect stages |
| `@motionactor/media` | `/vue`: views for `Image`, `Video` and `Audio` |
| `@motionactor/engine` | `/vue`: `mountVueComposition` |
| `@motionactor/hyperframes` | `/vue`: `mountHyperframesComposition` |

A program loads one binding: importing `/vue` makes every `RenderNode` a VNode.

## Renderers

A renderer returns a VNode from `h`. The registry calls it as a pure function, so it calls no
composables; an actor whose view needs state renders a component.

```ts twoslash title="renderers.ts"
import { LayoutActor, signal } from "@motionactor/core";
import { dom, vStyle } from "@motionactor/dom/vue";
import { createRenderRuntime } from "@motionactor/renderers/vue";
import { h, withDirectives } from "vue";

export class Badge extends LayoutActor {
  label = signal("New");
  hue = signal(190);
}

const badge = dom.render(Badge, ({ actor }) =>
  withDirectives(h("span", actor.signals.label), [
    [vStyle, { padding: 8, background: `hsl(${actor.signals.hue} 80% 45%)` }],
  ]),
);

export const runtime = createRenderRuntime({
  backends: [dom],
  renderers: [badge],
});
```

A style that moves with the frame goes through `v-style` rather than `style`. Vue's `style`
patch writes every key of an object on each render; `v-style` writes only the declarations
whose value changed, so on a frame where only `hue` moves the badge above sets `background`
alone. Numbers take `px` as in React, and the server renders the record as a `style`
attribute. An element takes one of `v-style` and `style`, never both. In an SFC that imports
`vStyle` in `<script setup>`:

```vue
<span v-style="{ padding: 8, background: `hsl(${hue} 80% 45%)` }">{{ label }}</span>
```

A component behind a renderer may use `useHold`, `useMotionactorFrame` and the other
composables; each reader returns a ref. A host that places its children in the browser's flow
returns `renderChild(child, { flow })` for each one. The DOM backend measures a flow child while
something reads its [`measuredBox`](/docs/reactivity/channel#measured-boxes).

## Painting a scene

`SceneRenderer` resolves the frame and paints its layers. A composition's `render` returns it
as a VNode:

```ts twoslash title="badge-card.ts"
// @filename: badge-renderers.ts
import { LayoutActor, signal } from "@motionactor/core";
import { dom } from "@motionactor/dom/vue";
import { createRenderRuntime } from "@motionactor/renderers/vue";
import { h } from "vue";
export class Badge extends LayoutActor {
  label = signal("New");
  hue = signal(190);
}
const badge = dom.render(Badge, ({ actor }) => h("span", actor.signals.label));
export const runtime = createRenderRuntime({ backends: [dom], renderers: [badge] });
// @filename: badge-card.ts
// ---cut---
import { createScene } from "@motionactor/core";
import {
  defineComposition,
  SceneRenderer,
} from "@motionactor/renderers/vue";
import { h } from "vue";
import { Badge, runtime } from "./badge-renderers";

const scene = createScene(function* (ctx) {
  const badge = ctx.spawn(Badge, { label: "Live" });
  yield* badge.hue.tween(320, 1);
  return { badge };
});

export const badgeCard = defineComposition({
  id: "BadgeCard",
  width: 1280,
  height: 720,
  fps: 30,
  defaultProps: {},
  runtime,
  scene,
  render: ({ compiled, frame, width, height }) =>
    h(SceneRenderer, { compiled, frame, width, height }),
});
```

Outside a composition, `provideRenderRuntime`, `provideFrameSource` and `provideFrameGate` set
the runtime, an `ExternalFrameSource` and a `PromiseFrameGate` for everything below them.

## Transitions

`Transition` takes the actor and a default slot. Each `Shared` is tagged by name, so it morphs
between the actor's views. With the `Standings` actor from [Transitions](/docs/rendering/transitions):

```ts twoslash title="standings-view.ts"
// @filename: standings.ts
import { defineShared, LayoutActor, signal } from "@motionactor/core";
export type Team = { name: string; points: number };
export class Standings extends LayoutActor {
  static shared = defineShared({
    row: (name: string) => `row:${name}`,
  });
  teams = signal<Team[]>([]);
}
// @filename: standings-view.ts
// ---cut---
import { dom } from "@motionactor/dom/vue";
import { Shared, Transition } from "@motionactor/renderers/vue";
import { h } from "vue";
import { Standings } from "./standings";

const { row } = Standings.shared;

export const standingsView = dom.render(
  Standings,
  ({ actor }) =>
    h(Transition, { actor }, {
      default: (table: typeof actor) =>
        h("ol", table.signals.teams.map((team) =>
          h(
            Shared,
            { key: team.name, name: row(team.name) },
            () => `${team.name} ${team.points}`,
          ))),
    }),
);
```

## Effects

`domStyle` effects, overlays included, paint unchanged. Canvas effects such as `glShader`
need the browser's HTML-in-canvas; call `provideHtmlCanvas(nativeHtmlCanvas)` once near the
root. Where the browser lacks it, the content paints unchanged under an overlay naming the
missing browser APIs.

## Hosts

`mountVueComposition` from `@motionactor/engine/vue` mounts a composition as the native
preview, with the controller [Hosts](/docs/rendering/hosts) describes.
`@motionactor/hyperframes/vue` mounts it for HyperFrames. Headless capture reads the binding from
the composition:

```ts twoslash title="session.ts"
import { openHeadlessSession } from "@motionactor/engine/node";

const session = await openHeadlessSession({
  entry: {
    entry: "./src/badge-card.ts",
    export: "badgeCard",
  },
});
const jpeg = await session.still(30);
await session.close();
```
