Remotion card
A complete composition in two files — slots, a declared child, a channel, derived state, scene exports and the Remotion seam. Typechecked and behavior-checked in CI.
This example is deliberately small and deliberately complete. It depends on no actor families and no media assets, and it is checked in this repository on every push.
What it demonstrates
signalholds authored progress.Card.reveal({ duration })is a semantic command; its one-second tween becomes 30 frames because scene and composition both useFPS = 30.defineSlotsnames thecaptionposition, and the fieldcaption = child(Caption)creates its occupant. There is one hosting model;hostInSlotis for occupants chosen conditionally during compilation.channelholds the host-provided available width, andderivedcombines that input with authored progress.- The per-resolution override with
createCellKeysupplies the width without mutating the publication store during React rendering. Repeating a frame with the same inputs produces the same width. - Scene exports give the view typed access to the card and caption — no casts, no lookup by string type.
defineSceneCompositionsupplies the renderer runtime and the Remotion host binding, and derives duration through metadata.
card.reveal({ duration: 1 })ctx.wait(1)60 frames total. progress reaches 1 at frame 30 and holds; revealWidth follows it,
scaled by whatever width the host published.
scene.ts
import {
Actor,
LayoutActor,
channel,
child,
createCellKey,
createScene,
defineSlots,
derived,
signal,
slot,
type Duration,
} from "@motionactor/core";
export class Caption extends Actor {
text = signal("MotionActor");
}
const cardSlots = defineSlots({ caption: slot({ accepts: Caption }) });
export class Card extends LayoutActor<typeof cardSlots> {
static slots = cardSlots;
caption = child(Caption);
progress = signal(0);
// The host owns this input; the authoring generator never writes it.
availableWidth = channel({ default: 640 });
revealWidth = derived(() => this.progress() * this.availableWidth());
*reveal({ duration }: { duration: Duration }) {
yield* this.progress.tween(1, duration);
}
}
export const FPS = 30;
export function buildScene() {
return createScene(
function* (ctx) {
const card = ctx.spawn(Card);
yield* card.reveal({ duration: 1 });
yield* ctx.wait(1);
return { card, caption: card.caption };
},
{ fps: FPS },
);
}
// A per-resolution host input keeps seeking independent of publication order.
export function resolveScene(
compiled: ReturnType<typeof buildScene>,
frame: number,
width: number,
) {
const card = compiled.at(frame).scene.card;
if (!card) return compiled.at(frame);
return compiled.at(frame, {
channelValues: new Map([[createCellKey(card.id, "availableWidth"), width]]),
});
}composition.tsx
import { AbsoluteFill, Composition, registerRoot } from "remotion";
import { defineSceneComposition } from "@motionactor/dom";
import { buildScene, FPS, resolveScene } from "./scene";
export const CardComposition = defineSceneComposition({
id: "ActorCard",
width: 1280,
height: 720,
fps: FPS,
buildScene,
render: ({ compiled, frame, width }) => {
const { card, caption } = resolveScene(compiled, frame, width * 0.75).scene;
if (!card || !caption) return <AbsoluteFill />;
// This small composition renders its typed scene exports directly.
// Reusable actor renderers can instead be dispatched by SceneRenderer.
return (
<AbsoluteFill className="items-center justify-center bg-slate-950 text-white">
<div
className="overflow-hidden rounded-xl bg-indigo-600 p-8 text-6xl whitespace-nowrap"
style={{ width: card.revealWidth, opacity: card.progress }}
>
{caption.text}
</div>
</AbsoluteFill>
);
},
});
export function RemotionRoot() {
return <Composition {...CardComposition.remotion} />;
}
registerRoot(RemotionRoot);Using it in a Remotion app
Copy both files into an existing Remotion app and use composition.tsx as its entrypoint.
The app needs React, Remotion, @motionactor/core and @motionactor/dom at compatible
versions, plus access to GitHub Packages — see Getting started.
Enable Tailwind for the static classes in the view. The only inline styles are resolved animation values. No downloaded assets are required.
If the app already calls registerRoot, drop the example's call and add
<Composition {...CardComposition.remotion} /> to the existing root.
How it is checked
From the repository root:
pnpm typecheck:examples
pnpm test:examplesThe TypeScript configuration checks the actual source files against the workspace public entrypoints. The behavior check covers midpoint timing, child identity, channel overrides and backward seeking. Both run in push CI.
These checks do not bundle or render a video, verify Tailwind output, or validate published tarballs in a fresh consumer — those remain separate release checks.
Traits are not needed here
Shared state uses group / layoutGroup; reusable animation
uses commands and the signal and effect primitives. Channels replaced the removed binding
primitives — they do not drive compile-time watches or turn external data into authored
timeline events.