Canvas stage
Frame-driven canvas drawing with export parity.
Customize
import {Scene, Video} from "odori";
import {CanvasStage} from "../components/canvas-stage/canvas-stage";
<Video>
<Scene id="canvas-stage" duration="5s">
<CanvasStage
count={120}
seed="field"
/>
</Scene>
</Video>Installation
pnpm odori add component canvas-stagenpx odori add component canvas-stagebunx odori add component canvas-stageEdit the source in videos/components/canvas-stage/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/canvas-stage/canvas-stage.tsx
import {Fill, random, useBrand, useCanvas, useDesignScale, useVideo, type CanvasDraw} from "odori";
export type CanvasStageProps = {
/** The draw call. Receives the 2D context and the frame it is drawing. */
draw?: CanvasDraw;
/** Canvas size in composition pixels. Defaults to the whole frame. */
width?: number;
height?: number;
/** Particles drawn by the built-in field, when no draw is supplied. */
count?: number;
/** Seed for the built-in field, so two videos can differ on purpose. */
seed?: string;
};
/**
* A canvas that is a function of the frame.
*
* Everything a composition can do with markup, it can do with a draw call —
* and a draw call can do the things markup cannot: sample pixels, warp,
* accumulate a trail without a thousand elements. What it must not do is
* animate itself. `requestAnimationFrame` is wall time, and wall time means an
* export samples wherever the loop happened to be, so two chunks disagree at
* their seam.
*
* The default draw is a field of particles positioned by seeded randomness and
* moved by the frame. It exists to make the contract legible: no state, no
* timers, no accumulation — the same number in, the same pixels out.
*/
export const CanvasStage = ({draw, width, height, count = 120, seed = "field"}: CanvasStageProps) => {
const brand = useBrand();
const scale = useDesignScale();
const video = useVideo();
const canvasWidth = width ?? video.width;
const canvasHeight = height ?? video.height;
const field: CanvasDraw = (context, {frame, width: w, height: h}) => {
context.fillStyle = brand.colors.accent;
for (let index = 0; index < count; index += 1) {
// Position comes from the seed; motion comes from the frame. Neither
// reads a clock, so frame 200 is the same picture on every run.
const x = random([seed, "x", index]) * w;
const drift = random([seed, "speed", index]) * 0.6 + 0.2;
const y = (random([seed, "y", index]) * h + frame * drift * scale * 2) % h;
const size = (random([seed, "size", index]) * 3 + 1) * scale;
context.globalAlpha = 0.15 + random([seed, "alpha", index]) * 0.5;
context.beginPath();
context.arc(x, y, size, 0, Math.PI * 2);
context.fill();
}
context.globalAlpha = 1;
};
const canvas = useCanvas(draw ?? field, [draw, brand.colors.accent, count, seed, scale]);
return (
<Fill style={{alignItems: "center", background: brand.colors.background, justifyContent: "center"}}>
<canvas ref={canvas} width={canvasWidth} height={canvasHeight} style={{height: "100%", width: "100%"}} />
</Fill>
);
};
videos/components/canvas-stage/canvas-stage.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {CanvasStage} from "./canvas-stage";
export default defineComponentPreview({
title: "Canvas stage",
category: "Media/Canvas",
description: "Frame-driven canvas drawing with export parity.",
component: CanvasStage,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {
count: {type: "number", defaultValue: 120, min: 10, max: 600},
seed: {type: "text", defaultValue: "field"},
},
examples: [
{name: "Default", props: {}},
{name: "Dense", props: {count: 400}},
{name: "Another seed", props: {seed: "launch"}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {CanvasStage} from "../components/canvas-stage/canvas-stage";
<Video>
<Scene id="canvas-stage" duration="5s">
<CanvasStage
count={120}
seed="field"
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Media
- Recommended duration
- 150 frames · 5s
- Minimum duration
- 30 frames · 1s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- the field holds its first frame
- Requires
- Odori
- Content limits
- count ≤ 600
Props
| Prop | Type | Default | Required |
|---|---|---|---|
drawThe draw call. Receives the 2D context and the frame it is drawing. | CanvasDraw | — | — |
widthCanvas size in composition pixels. Defaults to the whole frame. | number | — | — |
height | number | — | — |
countParticles drawn by the built-in field, when no draw is supplied. | number | 120 | — |
seedSeed for the built-in field, so two videos can differ on purpose. | string | "field" | — |
Related components
All componentsB-roll windowSupporting footage framed beside the main narrative.Canvas magnifierA magnifying lens over your content.CarouselA paced media sequence with stable aspect handling.CRT terminalA retro terminal with curved glass and glowing text.Glare sweepA light reflection that sweeps across your content.Halftone printText revealed as a pattern of printed dots.