---
title: Remotion card
description: 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

- `signal` holds authored progress. `Card.reveal({ duration })` is a semantic command; its
  one-second tween becomes 30 frames because scene and composition both use `FPS = 30`.
- `defineSlots` names the `caption` position, and the field `caption = child(Caption)`
  creates its occupant. There is [one hosting model](/docs/runtime/structure-and-hosting);
  `hostInSlot` is for occupants chosen conditionally during compilation.
- `channel` holds the host-provided available width, and `derived` combines that input with
  authored progress.
- The per-resolution override with `createCellKey` supplies 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.
- `defineSceneComposition` supplies the renderer runtime and the Remotion host binding, and
  derives duration through metadata.

<Timeline
  fps={30}
  seconds={2}
  tracks={[
    { label: "card.reveal({ duration: 1 })", start: 0, duration: 1, note: "30f" },
    { label: "ctx.wait(1)", start: 1, duration: 1, note: "30f" },
  ]}
>
  60 frames total. `progress` reaches 1 at frame 30 and holds; `revealWidth` follows it,
  scaled by whatever width the host published.
</Timeline>

## scene.ts

```ts title="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

```tsx title="composition.tsx"
import { AbsoluteFill, Composition, registerRoot } from "remotion";
import { defineSceneComposition } from "@motionactor/remotion";
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](/docs/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:

```bash
pnpm typecheck:examples
pnpm test:examples
```

The 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.

:::info[Traits are not needed here]
  Shared state uses [`group`](/docs/reactivity/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.
:::
