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: CompositionEntryThe 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();