HTML canvas
HTML authored into a canvas-safe rendered surface.
Customize
import {Scene, Video} from "odori";
import {HtmlCanvas} from "../components/html-canvas/html-canvas";
<Video>
<Scene id="html-canvas" duration="3s">
<HtmlCanvas
html={`
<div style="display:flex;flex-direction:column;gap:18px;padding:64px;border:1px solid #232323;border-radius:24px;background:#0c0c0c;">
<div style="font-size:26px;letter-spacing:0.08em;text-transform:uppercase;color:#8f8f8f;">Deployment</div>
<div style="font-size:76px;font-weight:600;letter-spacing:-0.03em;color:#f5f5f5;">odori.dev</div>
<div style="font-size:30px;color:#8f8f8f;">Production · 48s · ready</div>
</div>
`}
width={1100}
height={460}
entranceFrames={12}
/>
</Scene>
</Video>Installation
pnpm odori add component html-canvasnpx odori add component html-canvasbunx odori add component html-canvasEdit the source in videos/components/html-canvas/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/html-canvas/html-canvas.tsx
import {useEffect, useRef, useState} from "react";
import {Easing, Fill, drawHtml, interpolate, useBrand, useFrame, useReadiness, useVideo} from "odori";
export type HtmlCanvasProps = {
/** Markup to rasterize. Every element must carry inline styles. */
html: string;
/** Style applied to the wrapper inside the SVG. */
style?: string;
/** Canvas size in composition pixels. Defaults to the whole frame. */
width?: number;
height?: number;
/** Frames the result takes to fade up. */
entranceFrames?: number;
};
/**
* HTML drawn into the frame as pixels rather than as live DOM.
*
* The reason to want this is compositing: once markup is a canvas it can be
* warped, masked, blurred, sampled, or fed to another draw call, none of which
* a live DOM subtree can do. The reason it needs a component rather than a
* snippet is that getting it right means holding the frame until the raster
* lands and waiting for fonts first, and both are easy to forget once and hard
* to notice afterwards.
*
* The markup is self-contained by necessity. An SVG `foreignObject` cannot see
* the page's stylesheets, so styles are inline and images are data URLs; there
* is no way to make that ergonomic, so it is at least stated plainly and the
* failure names it.
*/
export const HtmlCanvas = ({html, style, width, height, entranceFrames = 12}: HtmlCanvasProps) => {
const frame = useFrame();
const brand = useBrand();
const video = useVideo();
const readiness = useReadiness();
const canvas = useRef<HTMLCanvasElement | null>(null);
const [failed, setFailed] = useState<string | null>(null);
const canvasWidth = width ?? video.width;
const canvasHeight = height ?? video.height;
// The raster is asynchronous, so it cannot use the synchronous useCanvas
// path: the frame is held by hand until the image has been drawn.
useEffect(() => {
const element = canvas.current;
if (!element) return;
const context = element.getContext("2d");
if (!context) return;
let cancelled = false;
const release = readiness.hold();
context.clearRect(0, 0, element.width, element.height);
drawHtml(context, html, {
width: canvasWidth,
height: canvasHeight,
style: style ?? `color:${brand.colors.foreground};font-family:${brand.typography.sans};`,
})
.catch((error: Error) => {
if (!cancelled) setFailed(error.message);
})
.finally(() => {
if (!cancelled) release();
});
return () => {
cancelled = true;
release();
};
}, [brand.colors.foreground, brand.typography.sans, canvasHeight, canvasWidth, html, readiness, style]);
const appear = interpolate(frame, [0, entranceFrames], [0, 1], {easing: Easing.standard});
return (
<Fill style={{alignItems: "center", justifyContent: "center"}}>
<canvas
ref={canvas}
width={canvasWidth}
height={canvasHeight}
style={{height: canvasHeight, opacity: appear, width: canvasWidth}}
/>
{failed ? (
<span
style={{
color: brand.colors.muted,
fontFamily: brand.typography.mono,
fontSize: 24,
maxWidth: "70%",
position: "absolute",
textAlign: "center",
}}
>
{failed}
</span>
) : null}
</Fill>
);
};
videos/components/html-canvas/html-canvas.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {HtmlCanvas} from "./html-canvas";
const CARD = `
<div style="display:flex;flex-direction:column;gap:18px;padding:64px;border:1px solid #232323;border-radius:24px;background:#0c0c0c;">
<div style="font-size:26px;letter-spacing:0.08em;text-transform:uppercase;color:#8f8f8f;">Deployment</div>
<div style="font-size:76px;font-weight:600;letter-spacing:-0.03em;color:#f5f5f5;">odori.dev</div>
<div style="font-size:30px;color:#8f8f8f;">Production · 48s · ready</div>
</div>
`;
export default defineComponentPreview({
title: "HTML canvas",
category: "Media/Canvas",
description: "HTML authored into a canvas-safe rendered surface.",
component: HtmlCanvas,
canvas: {width: 1920, height: 1080, duration: "3s"},
controls: {
entranceFrames: {type: "number", defaultValue: 12, min: 0, max: 60},
},
examples: [
{name: "Default", props: {html: CARD, width: 1100, height: 460}},
{
name: "Full frame",
props: {
html: `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:96px;color:#f5f5f5;">Rasterized</div>`,
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {HtmlCanvas} from "../components/html-canvas/html-canvas";
<Video>
<Scene id="html-canvas" duration="3s">
<HtmlCanvas
html={`
<div style="display:flex;flex-direction:column;gap:18px;padding:64px;border:1px solid #232323;border-radius:24px;background:#0c0c0c;">
<div style="font-size:26px;letter-spacing:0.08em;text-transform:uppercase;color:#8f8f8f;">Deployment</div>
<div style="font-size:76px;font-weight:600;letter-spacing:-0.03em;color:#f5f5f5;">odori.dev</div>
<div style="font-size:30px;color:#8f8f8f;">Production · 48s · ready</div>
</div>
`}
width={1100}
height={460}
entranceFrames={12}
/>
</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
- 90 frames · 3s
- Minimum duration
- 24 frames · 0.8s
- Entrance
- 12 frames
- Exit
- 0 frames
- Reduced motion
- the raster appears without fading in
- Requires
- sans font
- Content limits
- html ≤ 4000
Props
| Prop | Type | Default | Required |
|---|---|---|---|
htmlMarkup to rasterize. Every element must carry inline styles. | string | — | Yes |
styleStyle applied to the wrapper inside the SVG. | string | — | — |
widthCanvas size in composition pixels. Defaults to the whole frame. | number | — | — |
height | number | — | — |
entranceFramesFrames the result takes to fade up. | number | 12 | — |
Related components
All componentsB-roll windowSupporting footage framed beside the main narrative.Canvas magnifierA magnifying lens over your content.Canvas stageFrame-driven canvas drawing with export parity.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.