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

Svelte

Paint scenes with Svelte 5 through @motionactor/renderers/svelte and @motionactor/dom/svelte.

Scenes, actors and effects are the same under every binding. A Svelte app changes two things: its renderers are snippets or components, and it mounts through the /svelte entries.

Package Svelte entry
@motionactor/renderers /svelte: SceneRenderer, renderers, contexts, compositions, transitions, media
@motionactor/dom /svelte: dom (the backend), DomHost, ActorElement, effect stages
@motionactor/media /svelte: ImageView, VideoView, AudioView, mediaRendererRegistry, withMediaFamily
@motionactor/engine /svelte: mountSvelteComposition
@motionactor/hyperframes /svelte: mountHyperframesComposition

The /svelte entries ship .svelte sources, so the app’s own Svelte 5 compiler builds them (@sveltejs/vite-plugin-svelte under Vite). A program loads one binding: importing /svelte makes every RenderNode a Svelte node.

Renderers

A renderer draws one actor class. A snippet is the usual form, since a snippet holds no state and the registry calls renderers as pure functions. Export them from a module script:

<script module lang="ts">
  import type {
    ActorRendererContextForBlueprint,
  } from "@motionactor/renderers/svelte";
  import type { Badge } from "./scene";

  export { badge };
</script>

{#snippet badge({ actor }: ActorRendererContextForBlueprint<typeof Badge>)}
  <span style:background="hsl({actor.signals.hue} 80% 45%)">
    {actor.signals.label}
  </span>
{/snippet}

An actor whose view needs state renders a component. It receives the same context as props and may use runes, useHold and the other use* contexts:

<script lang="ts">
  import {
    useHold,
    type ActorRendererContextForBlueprint,
  } from "@motionactor/renderers/svelte";
  import type { Photo } from "./scene";

  let { actor }: ActorRendererContextForBlueprint<typeof Photo> = $props();
  let image: HTMLImageElement | undefined = $state();
  let decoded: string | undefined = $state();
  const src = $derived(actor.signals.src);

  // Capture waits on this frame until the image decodes.
  useHold(() => `Photo: ${src}`, () => decoded !== src);
  $effect(() => {
    const element = image;
    const current = src;
    if (!element) return;
    let active = true;
    void element.decode().finally(() => {
      if (active) decoded = current;
    });
    return () => {
      active = false;
    };
  });
</script>

<img bind:this={image} {src} alt="" />

the backend’s snippet and component register them:

import { dom } from "@motionactor/dom/svelte";
import { createRenderRuntime } from "@motionactor/renderers/svelte";
import { Badge, Photo } from "./scene";
import PhotoView from "./PhotoView.svelte";
import { badge } from "./views.svelte";

export const runtime = createRenderRuntime({
  backends: [dom],
  renderers: [
    dom.snippet(Badge, badge),
    dom.component(Photo, PhotoView),
  ],
});

A host that places its children in the browser’s flow paints each one through Node:

{#snippet row({
  slots,
  renderChild,
}: ActorRendererContextForBlueprint<typeof Row>)}
  <div class="flex items-center gap-3">
    {#each slots.chips as { actor } (actor.id)}
      <Node node={renderChild(actor, { flow: {} })} />
    {/each}
  </div>
{/snippet}

The DOM backend measures a flow child while something reads its measuredBox.

Painting a scene

provideRenderRuntime sets the runtime for everything below it. SceneRenderer resolves the frame and paints its layers:

<script lang="ts">
  import type { CompiledScene } from "@motionactor/core";
  import {
    SceneRenderer,
    provideRenderRuntime,
  } from "@motionactor/renderers/svelte";
  import { runtime } from "./renderers";

  let { compiled, frame }: {
    compiled: CompiledScene<unknown>;
    frame: number;
  } = $props();
  provideRenderRuntime(runtime);
</script>

<SceneRenderer {compiled} {frame} width={1280} height={720} />

provideFrameSource and provideFrameGate take the same ExternalFrameSource and PromiseFrameGate as the other bindings. useMotionactorFrame, useMotionactorViewport and the other readers return a Current<T>, whose current is reactive.

Transitions

Transition takes the actor and a children snippet. Each Shared is tagged by name, so it morphs between the actor’s views. With the Standings actor from Transitions:

{#snippet standings({
  actor,
}: ActorRendererContextForBlueprint<typeof Standings>)}
  <Transition {actor}>
    {#snippet children(table)}
      <ol>
        {#each table.signals.teams as team (team.name)}
          <Shared name={Standings.shared.row(team.name)}>
            {team.name} {team.points}
          </Shared>
        {/each}
      </ol>
    {/snippet}
  </Transition>
{/snippet}

Effects

domStyle effects, overlays included, paint unchanged. Canvas effects such as glShader need the browser’s HTML-in-canvas; provide it once near the root:

<script lang="ts">
  import {
    nativeHtmlCanvas,
    provideHtmlCanvas,
  } from "@motionactor/renderers/svelte";

  provideHtmlCanvas(nativeHtmlCanvas);
</script>

Where the browser lacks it, the content paints unchanged under an overlay naming the missing browser APIs.

Compositions and hosts

A composition’s render returns a Svelte node; component wraps a component and its props:

import {
  component,
  defineComposition,
} from "@motionactor/renderers/svelte";
import { titleScene } from "./scene";
import { runtime } from "./renderers";
import Stage from "./Stage.svelte";

export const titleCard = defineComposition({
  id: "TitleCard",
  width: 1280,
  height: 720,
  fps: 30,
  runtime,
  scene: titleScene,
  render: ({ compiled, frame }) => component(Stage, { compiled, frame }),
});

mountSvelteComposition from @motionactor/engine/svelte is the native preview, with the controller Hosts describes. @motionactor/hyperframes/svelte mounts it for HyperFrames. Headless capture reads the binding from the composition:

import { function openHeadlessSession(options: HeadlessSessionOptions): Promise<HeadlessSession>
Open a composition in headless Chromium. The page is the one Studio previews (`buildCompositionPreview`), served from loopback; frames come out through CDP screenshots (`@motionactor/engine/capture/cdp`). An abort or a failure while opening releases everything it had started, the browser included.
openHeadlessSession
} from "@motionactor/engine/node";
const const session: HeadlessSessionsession = await function openHeadlessSession(options: HeadlessSessionOptions): Promise<HeadlessSession>
Open a composition in headless Chromium. The page is the one Studio previews (`buildCompositionPreview`), served from loopback; frames come out through CDP screenshots (`@motionactor/engine/capture/cdp`). An abort or a failure while opening releases everything it had started, the browser included.
openHeadlessSession
({
HeadlessSessionOptions.entry: CompositionEntry
The composition, as `bundleComposition`/`buildCompositionPreview` take it.
entry
: {
CompositionEntry.entry: stringentry: "./src/composition.ts", CompositionEntry.export: stringexport: "titleCard", }, }); const const jpeg: Uint8Array<ArrayBufferLike>jpeg = await const session: HeadlessSessionsession.HeadlessSession.still(frame: number, options?: HeadlessStillOptions): Promise<Uint8Array>
A JPEG of `frame`. Captures only that frame.
still
(30);
await const session: HeadlessSessionsession.HeadlessSession.close(): Promise<void>
Waits for the running operation, then releases the browser, server and build. Idempotent.
close
();

Was this page helpful?