write-overlay · git:20260929.b18df43 · 2026-09-29 · sha256 71a7370b222bc217
write-overlay git:20260929.b18df43A
Immutable. This exact content never changes and is served at /api/v1/blob/71a7370b222bc217.
---
name: write-overlay
description: "Write a custom JSX overlay component and add it to the project's overlay track."
---
# Write Overlay
An overlay is a React component rendered frame-by-frame by Puppeteer, composited over the footage at a specific timestamp. All overlays are custom JSX — there are no built-in templates.
> See [MOTION.md](MOTION.md) for sandbox facts that matter once an overlay moves (SVG filters, the `duration` guard, beat math) and for how to verify motion by sampling.
---
## Execution context
Custom overlay JSX runs in a sandboxed evaluator. All identifiers below are injected as globals:
| Identifier | Type | Description |
|------------|------|-------------|
| `frame` | number | Current frame number (0 → duration-1). Drives all animation. |
| `fps` | number | Output frame rate |
| `duration` | number | Total frames this overlay is visible for |
| `props` | object | The `props` object from the project.json item |
| `interpolate` | function | Map a frame number to any output value |
| `spring` | function | Physics-based easing (0 → 1) |
| `springStep` | function | Closed-form spring from `from` to `to` starting at frame `at`. A pure function of `frame` (exact at fractional frames, no cache), so it stays correct under sub-frame motion blur. |
| `springSum` | function | A value that changes target several times: one closed-form spring per entry in `changes`, summed. Retargets mid-flight without restarting, and any frame renders without simulating the ones before it. |
| `Ph` | object | All [Phosphor Icons](https://phosphoricons.com) — e.g. `Ph.House`, `Ph.ArrowRight` |
| `FaIcon` | component | `FontAwesomeIcon` renderer — use with `FaSolid` / `FaBrands` icon objects |
| `FaSolid` | object | All [FA Free Solid](https://fontawesome.com/icons?s=solid) icon objects — e.g. `FaSolid.faHouse` |
| `FaBrands` | object | All [FA Free Brands](https://fontawesome.com/icons?s=brands) icons — e.g. `FaBrands.faGithub` |
| `THREE` | namespace | All [Three.js](https://threejs.org) primitives — `THREE.Vector3`, `THREE.MathUtils`, etc. Only reach for it when you genuinely need 3D — see "3D / Three.js" section. |
| `Canvas` | component | [@react-three/fiber](https://r3f.docs.pmnd.rs) Canvas. **Always pass `frameloop="never"`** and mount a `useThreeFrame()` child — see "3D / Three.js" section. |
| `useThreeFrame` | hook | Bridges r3f to Montaj's frame-stepped renderer. Mount exactly once inside any `<Canvas>`. |
| `useCanvas2DFrame` | hook | Drives a plain `CanvasRenderingContext2D` from `frame` — for pixel-level 2D drawing HTML/CSS can't do (per-pixel effects, arbitrary paths, `drawImage` compositing). See "2D Canvas" section. |
**Availability note:** `useCanvas2DFrame` is provided by the renderer. If your CLI's local cache (`~/.cache/montaj`) was populated before this hook was added, running `montaj install ui` updates it.
**Imports.** An overlay can import your own files: relative paths (`./lib/palette.js`, `../_shared/ease.ts`, `.json` data) and `montaj/render` resolve in both the editor preview and render, and `react` resolves to the editor's React. The preview only follows files under the workspace, the global overlay library (`~/.montaj/overlays`) and profile folders; an import from anywhere else shows an error badge. An import that doesn't resolve (e.g. `from 'remotion'`) shows an error in the preview and fails at render. Editing an imported file refreshes the preview.
The globals above stay the way to get `frame`, `fps`, `duration`, `props`, `interpolate`, `spring`, `THREE`, `Canvas`, `useThreeFrame`, `Ph`, `FaIcon`, `FaSolid`, `FaBrands` and the chart components. Do not `import` `three`, `@react-three/fiber`, `recharts`, Phosphor or FontAwesome: render cannot resolve those packages from overlay code, and the preview fails the same way. Use the globals.
**Never hardcode frame counts; projects can be 24, 30 or 60fps.** A literal like `[0, 10]` means a different real-world duration on every project — 10 frames is 0.33s at 30fps but 0.17s at 60fps, so an overlay copied between projects (or a 30fps example copied into a 60fps project, which is the current default) plays at the wrong speed. Always derive the frame count from `fps`: `Math.round(fps * 0.33)` for "about a third of a second," not `10`.
### Top-level vs component-body
**All calls to `interpolate`, `spring`, and any read of `frame`, `fps`, `duration`, or `props` must be inside the component function body.** The module's top-level code runs before the render shim sets up these globals — calling them outside a function will throw `interpolate is not defined` and crash the entire render. The same goes for a shared module you import: it must export functions and read `frame`, `fps` and the other globals only inside them, because its top level runs before the globals are set.
**With `settings.motionBlur` above 1, the renderer passes fractional `frame` values (sub-frames). Never index arrays by `frame` or branch on `frame % n`; derive everything as a continuous function of `frame`.**
```jsx
// WRONG — crashes at render time
const opacity = interpolate(frame, [0, Math.round(fps * 0.33)], [0, 1])
export default function Hook() { ... }
// CORRECT — inside the component, runs each frame
export default function Hook() {
const opacity = interpolate(frame, [0, Math.round(fps * 0.33)], [0, 1])
return <div style={{ opacity }}>...</div>
}
```
Pure helper functions that receive their values as arguments are fine at the top level, as long as they don't call globals at definition time:
```jsx
// Fine — interpolate is only called when the function is invoked (inside the component)
const itemStyle = (show) => ({
opacity: show,
transform: `translateY(${interpolate(show, [0, 1], [20, 0])}px)`,
})
export default function List() {
const show = spring({ frame, fps, stiffness: 300, damping: 24 })
return <div style={itemStyle(show)}>...</div>
}
```
---
## Writing the JSX
> **Expose text styling as props to make an overlay editable.** A text overlay is only restyleable in the editor's properties panel for the props it declares — see "Make text overlays editable in the properties panel" below, which applies to video overlays too. **Carousel text overlays follow a stricter, required version of that contract:** every text-bearing overlay must accept its font size, family, weight, style, color, alignment, transform, and background as props with string defaults — see skill `editable-text`.
When an overlay needs a background, prefer a solid semi-transparent color over `backdropFilter: blur()`. See the track-splitting section below.
### Rules
- **Default export only** — the evaluator imports the default export
- **No hooks** — `useState`, `useEffect`, etc. are not supported in the overlay component itself. The render shim drives re-renders by calling `flushSync` externally each frame; the component must be a pure function of its props/globals.
- **Frame-driven** — all animation must derive from `frame`. No `setTimeout`, `setInterval`, CSS `animation`, or `transition`.
- **Transparent background (default)** — overlays render with a transparent background by default. Do not set `background` on the root element; it will obscure whatever is beneath it.
- **Opaque overlays** — when `"opaque": true` is set on the item in project.json, the root element's CSS controls the entire frame. You may freely set `background`, gradients, images, or any CSS on the root. Use this for full-frame covers, title cards, and animation sections.
- **Absolute positioning** — the component fills the full video frame (1080 on the short edge, aspect of `project.settings.resolution`). The Puppeteer viewport is always 1080-short-edge regardless of output resolution; the renderer upscales to the final video dimensions at compose time. Place elements with `position: absolute`. Author all `fontSize`, padding, and `width` values at 1080-design coordinates — they have one consistent meaning across every resolution the project might render at.
- **No side effects** — no API calls, no filesystem access, no global state mutations.
- **`backdropFilter` caution** — `backdrop-filter: blur(...)` causes Chrome to create a separate GPU compositor layer that can be cached and replayed as a stale frame during rendering. Avoid putting `backdrop-filter` on any element whose children animate — the blur container will flash or freeze. See the track-splitting guidance below.
---
## Make text overlays editable in the properties panel
The editor's right-hand properties panel (and the floating text toolbar) can restyle a text overlay **only for the props it actually declares**. It reads the nine standard text props off the item's `props` object:
`text`, `fontSize`, `fontFamily`, `fontWeight`, `fontStyle`, `color`, `textAlign`, `textTransform`, `bgColor`
A control appears for each of those that is present (non-null) on `props`; anything the overlay **hardcodes** in its JSX style instead of reading from `props` shows **no control at all**, and the operator can't change it. This is exactly why an overlay that writes `textTransform: 'uppercase'` straight into its style has no text-transform control in the editor.
**So a text overlay you want a human to be able to restyle must READ its text styling from `props`, with sensible defaults — not hardcode it.** For example:
```jsx
// Editable Hook — every text property is adjustable in the panel.
export default function Hook() {
const progress = interpolate(frame, [0, Math.round(fps * 0.27)], [0, 1])
const slideY = interpolate(frame, [0, Math.round(fps * 0.33)], [40, 0])
return (
<div style={{ position: 'absolute', bottom: 180, left: 48, right: 48, opacity: progress, transform: `translateY(${slideY}px)` }}>
<div style={{
fontFamily: props.fontFamily ?? 'Anton, Impact, sans-serif',
fontSize: props.fontSize ?? 120,
fontWeight: props.fontWeight ?? 900,
fontStyle: props.fontStyle ?? 'normal',
color: props.color ?? '#fff',
textAlign: props.textAlign ?? 'left',
textTransform: props.textTransform ?? 'uppercase',
background: props.bgColor ?? 'transparent',
lineHeight: 1.05, letterSpacing: '-1px',
textShadow: '0 2px 24px rgba(0,0,0,0.9), 0 0 60px rgba(0,0,0,0.5)',
}}>
{props.text}
</div>
</div>
)
}
```
Declare those same values in the item's `props` too, so the panel opens on the real values rather than blank controls:
```json
"props": {
"text": "She built an AI employee",
"fontSize": 120, "fontFamily": "Anton, Impact, sans-serif",
"fontWeight": 900, "fontStyle": "normal", "color": "#ffffff",
"textAlign": "left", "textTransform": "uppercase", "bgColor": "transparent"
}
```
This now applies to **video** text overlays, not just carousel slides — the video editor has the same properties panel. For the full contract (types and validation) see skill `editable-text`. A non-text overlay (logo, image card, chart) has no text props to expose; the same principle still holds for whatever a human would want to tweak — pass it through `props`, don't bury it in the JSX.
---
## Splitting background from content across tracks
The most reliable way to use frosted-glass / blurred card backgrounds is to **put the background on a separate, lower track** and the animated content on a higher track. The render pipeline composites tracks in order, so the content renders on top.
**Why this works:** A background card with `backdrop-filter` is essentially static — it fades in, then stays put. When Chrome's headless compositor caches the GPU layer for it, the cache is *correct* (the layer genuinely hasn't changed). The content overlay on the higher track has no `backdrop-filter`, so there's no caching issue and animations render cleanly every frame.
**When to split:**
| Background behavior | Animated content | Verdict |
|---------------------|-----------------|---------|
| Static or simple fade only | Any — text, icons, logos staggering in | **Split** |
| Shakes, bounces, or translates together with content | Content must move with the background | **Keep together** (no backdrop-filter, use solid `background` instead) |
**How to split in project.json:**
```json
{
"tracks": [
{ "id": "trk-0", "items": [] },
{
"id": "trk-1",
"items": [
{
"id": "ov-card-bg",
"type": "overlay",
"src": "/path/overlays/card-bg.jsx",
"start": 2.0,
"end": 6.0
}
]
},
{
"id": "trk-2",
"items": [
{
"id": "ov-card-content",
"type": "overlay",
"src": "/path/overlays/card-content.jsx",
"start": 2.0,
"end": 6.0
}
]
}
]
}
```
**Background component — no animated children:**
```jsx
// overlays/card-bg.jsx
// Just a frosted card that fades in. No children that animate opacity.
const opacity = interpolate(frame, [0, Math.round(fps * 0.27)], [0, 1])
export default function CardBg() {
return (
<div style={{ position: 'absolute', bottom: 340, left: 0, right: 0, display: 'flex', justifyContent: 'center', opacity }}>
<div style={{
background: 'rgba(0,0,0,0.84)',
backdropFilter: 'blur(24px)',
borderRadius: 36,
padding: '44px 72px',
border: '1px solid rgba(255,255,255,0.10)',
minWidth: 560,
minHeight: 200,
}} />
</div>
)
}
```
**Content component — no backdrop-filter:**
```jsx
// overlays/card-content.jsx
// Animated items rendered on top of the background card.
const s1 = spring({ frame: Math.max(0, frame - 4), fps, stiffness: 300, damping: 24 })
export default function CardContent() {
return (
<div style={{ position: 'absolute', bottom: 340, left: 0, right: 0, display: 'flex', justifyContent: 'center' }}>
<div style={{ padding: '44px 72px', minWidth: 560 }}>
<div style={{ opacity: Math.min(1, s1 * 2.5), transform: `translateX(${interpolate(s1, [0, 1], [-24, 0])}px)` }}>
<Ph.CheckCircle size={52} weight="fill" color="#34d399" />
</div>
</div>
</div>
)
}
```
**When you can't split** (background and content animate together as one unit — e.g., a card that shakes on impact), skip `backdrop-filter` entirely and use a solid or semi-transparent `background` instead:
```jsx
// Instead of backdropFilter: 'blur(24px)'
background: 'rgba(10,10,10,0.88)' // solid dark — visually similar, no GPU layer caching
```
---
## Utilities
### `interpolate(frame, inputRange, outputRange, options?)`
Maps a frame number to any output value. Clamps at both ends by default.
```jsx
// Fade in over the first ~0.5s
const holdIn = Math.round(fps * 0.5)
const opacity = interpolate(frame, [0, holdIn], [0, 1])
// Fade in then out, each over ~0.5s
const holdOut = Math.round(fps * 0.5)
const fadeIn = interpolate(frame, [0, holdIn], [0, 1])
const fadeOut = interpolate(frame, [duration - holdOut, duration], [1, 0])
const opacity = Math.min(fadeIn, fadeOut)
// Slide in from left over ~0.67s
const x = interpolate(frame, [0, Math.round(fps * 0.67)], [-200, 0])
```
One option: `extrapolate` — `'clamp'` (default) or `'extend'`. That is the whole options object; the runtime destructures `{ extrapolate = 'clamp' }` and ignores everything else. Earlier versions of this file used `extrapolateRight`, which does not exist and was silently dropped — harmless only because the default was already `'clamp'`.
**`interpolate` is strictly linear** — `interpolate(5, [0,10], [0,1])` is exactly `0.5`. There is no easing parameter; to shape a move, transform the normalised value before you map it to an output.
### `spring({ frame, fps, mass?, stiffness?, damping?, initialVelocity? })`
Returns a 0 → 1 value following spring physics. Overshoots and settles naturally.
```jsx
const scale = spring({ frame, fps, stiffness: 120, damping: 14 })
// transform: `scale(${scale})`
```
Defaults: `mass: 1`, `stiffness: 100`, `damping: 10`.
### `springStep(...)` / `springSum(...)`
Closed-form damped springs, as pure functions of `frame` — no memo, no simulation, exact at fractional frames.
```jsx
springStep(frame, fps, { from, to, at, mass, stiffness, damping, initialVelocity })
springSum({ frame, fps, from, changes: [{ at, to, mass?, stiffness?, damping? }], mass, stiffness, damping })
```
`springStep` releases one spring from `from` to `to` at frame `at` (holds `from` before it). `springSum` retargets several times: each entry in `changes` is a closed-form spring summed on top of the others, so the value can jump targets mid-flight without restarting the animation.
```jsx
export default function Indicator() {
const x = springSum({
frame, fps, from: 0,
changes: [
{ at: Math.round(fps * 0.5), to: 240 },
{ at: Math.round(fps * 1.2), to: 520 },
{ at: Math.round(fps * 2.0), to: 120 },
],
stiffness: 170, damping: 26,
})
return <div style={{ position: 'absolute', left: 100 + x, top: 500, width: 160, height: 8, background: '#fff' }} />
}
```
`spring()` interpolates linearly between whole frames at fractional frames; `springStep`/`springSum` are exact at any frame.
---
## Icons
Two icon libraries are available as globals, with no imports.
### Phosphor Icons — `Ph`
Browse at [phosphoricons.com](https://phosphoricons.com). Over 9000 icons, six weights: `regular` (default), `bold`, `fill`, `duotone`, `light`, `thin`.
```jsx
// Basic usage
<Ph.House size={48} color="white" />
// With weight
<Ph.ArrowRight size={32} color="#a78bfa" weight="bold" />
<Ph.Star size={40} color="#fbbf24" weight="fill" />
// In a card row
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Ph.CheckCircle size={36} color="#34d399" weight="fill" />
<span style={{ fontFamily: 'Inter, sans-serif', fontSize: 24, color: 'white' }}>Feature unlocked</span>
</div>
```
### Font Awesome — `FaIcon` + `FaSolid` / `FaBrands`
`FaIcon` is the renderer. `FaSolid` has general-purpose icons; `FaBrands` has logos (GitHub, YouTube, X, etc.).
```jsx
// Solid icon
<FaIcon icon={FaSolid.faCode} style={{ fontSize: 48, color: 'white' }} />
// Brand logo
<FaIcon icon={FaBrands.faGithub} style={{ fontSize: 48, color: 'white' }} />
// Sized via style
<FaIcon icon={FaSolid.faBolt} style={{ fontSize: 36, color: '#fbbf24' }} />
```
### Which library to use
- **Phosphor** — preferred for most overlays. Cleaner API, consistent stroke weight, large set.
- **Font Awesome Brands** — when you need a specific brand logo (GitHub, YouTube, X/Twitter, TikTok, etc.)
- **Font Awesome Solid** — for any icon Phosphor doesn't cover.
---
## Custom fonts (Google Fonts)
The render host's system fonts are always available and preferred for performance — they avoid the network fetch entirely. On macOS render hosts (today's default) you can rely on `Helvetica`, `Arial`, `Georgia`, `Times`, `Courier`, `Impact`, and the `system-ui` / `-apple-system` generic stacks. `Inter` is **not** a macOS system font — use a Google Font declaration for it.
To use a Google Font, declare it on the overlay item in `project.json` with a `googleFonts` array:
```json
{
"id": "ov-hook",
"type": "overlay",
"src": "/path/to/overlays/hook.jsx",
"start": 0.0,
"end": 5.0,
"googleFonts": ["Anton", "Playfair+Display:ital@1"]
}
```
The render engine injects the font stylesheet into the page `<head>` before any component code runs, so the font is fully loaded at frame 0.
**Do not use `@import url(...)` inside the JSX.** A dynamically-injected `@import` fires after the page loads — the font fetch is still in flight when the next overlay's page initialises, breaking its `window.__setFrame` setup. Always declare fonts in `googleFonts` instead, and reference the family name directly in styles:
```jsx
// In your JSX — just use the family name, no @import
fontFamily: '"Anton", Impact, sans-serif'
fontFamily: '"Playfair Display", Georgia, serif'
```
**Format:** `FamilyName` for regular, `FamilyName:ital@1` for italic, `FamilyName:wght@700` for a specific weight. Each family is a separate array entry.
**System font fallbacks for common Google Fonts:**
| Google Font | System fallback |
|-------------|----------------|
| Anton | Impact |
| Playfair Display | Georgia |
| Oswald | Arial Narrow |
| Roboto / Inter | system-ui, sans-serif |
If visual fidelity isn't critical, the system fallback avoids the network fetch entirely.
---
## 3D / Three.js (react-three-fiber)
Overlay JSX can use Three.js for real 3D content via [@react-three/fiber](https://r3f.docs.pmnd.rs). Reach for it when you need depth, particles, shader effects, 3D text, or geometry that can't be faked in 2D. **Do not use it for things 2D HTML can do** — rotations, fades, slides, gradients are all cheaper and simpler in CSS.
**Preview behavior — RAF-driven, not frame-stepped.** Three.js overlays render in both the live UI preview and the final rendered MP4. The two paths share globals and library versions via the `montaj-overlay-runtime` package, so what you see in the editor matches the render output visually. One difference to be aware of: in preview the 3D content animates via r3f's own `requestAnimationFrame` loop (the preview-context `Canvas` wrapper overrides `frameloop="never"` → `"always"` automatically), so motion is smooth but not perfectly frame-accurate to the scrubbed video position. In render, `frameloop="never"` is honored and the shim drives `gl.render()` synchronously each frame.
### Two non-negotiable rules
1. **`<Canvas frameloop="never">`.** The default r3f Canvas runs its own `requestAnimationFrame` loop, which is incompatible with Montaj's frame-stepped renderer — Puppeteer would screenshot arbitrary moments. `frameloop="never"` disables r3f's loop and lets the render shim drive each frame synchronously.
2. **Mount `useThreeFrame()` exactly once inside the Canvas.** This hook registers the synchronous render trigger the shim calls every frame. Without it, Three never draws.
Convention: put a tiny `<FrameBridge />` child component at the top of the Canvas that calls `useThreeFrame()` and returns `null`.
### Drive everything from `frame`, never `useFrame`
r3f's `useFrame` hook is tied to the internal animation loop we've disabled. **It does not work.** Compute transforms inline from the `frame` global, the same way 2D overlays do:
```jsx
export default function ThreeCube() {
const t = frame / fps
const rotX = t * Math.PI // half-turn per second
const rotY = t * Math.PI * 0.7
const pulse = 1 + 0.08 * Math.sin(t * 4)
return (
<Canvas
frameloop="never"
style={{ position: 'absolute', inset: 0 }}
camera={{ position: [0, 0, 5], fov: 50 }}
gl={{ preserveDrawingBuffer: true, antialias: true, alpha: true }}
>
<FrameBridge />
<ambientLight intensity={0.6} />
<directionalLight position={[5, 5, 5]} intensity={1.2} />
<mesh rotation={[rotX, rotY, 0]} scale={[pulse, pulse, pulse]}>
<boxGeometry args={[1.6, 1.6, 1.6]} />
<meshStandardMaterial color="#3b82f6" metalness={0.3} roughness={0.35} />
</mesh>
</Canvas>
)
}
function FrameBridge() {
useThreeFrame()
return null
}
```
### No async assets
Textures, GLTFs, and any other resource that r3f loads via Suspense will **not** load in time for frame 0 — the renderer doesn't wait for Suspense the way it waits for `document.fonts.ready`. Stick to:
- Primitive geometries: `<boxGeometry>`, `<sphereGeometry>`, `<planeGeometry>`, `<cylinderGeometry>`, `<torusGeometry>`, `<icosahedronGeometry>`, etc.
- `<meshStandardMaterial>` / `<meshBasicMaterial>` with `color` (no `map`, `normalMap`, etc.)
- Lights: `<ambientLight>`, `<directionalLight>`, `<pointLight>`, `<spotLight>`
- Math helpers from the `THREE` global (`THREE.MathUtils.lerp`, `THREE.Vector3`, etc.)
If you need a texture, render it on a 2D HTML overlay layered above the Canvas track instead.
### Canvas placement and compositing
`<Canvas>` fills its parent. Two patterns work:
**Full-frame Canvas** — apply `style={{ position: 'absolute', inset: 0 }}` directly to `<Canvas>` so it covers the whole 1080×1920 design canvas. Use when 3D content should occupy the entire frame or be anchored relative to the camera (e.g. a particle field).
**Positioned Canvas** — wrap `<Canvas>` in a `position: absolute` div with explicit `top`/`left`/`right`/`height` (or `bottom`), and set `style={{ width: '100%', height: '100%' }}` on the Canvas itself. Use when 3D content should sit in a specific region — e.g. a 3D logo lockup in the lower-third while a font overlay sits up top.
```jsx
<div style={{ position: 'absolute', top: 1100, left: 0, right: 0, height: 500 }}>
<Canvas frameloop="never" style={{ width: '100%', height: '100%' }} ...>
<FrameBridge />
...
</Canvas>
</div>
```
Make sure `gl={{ alpha: true }}` is set — the Canvas DOM element defaults to opaque, which would paint a black or white box over the underlying footage. With `alpha: true` + the renderer's default transparent page background, only the drawn 3D geometry shows up; the rest passes through to whatever is beneath.
For text labels alongside 3D content, render them as a 2D HTML overlay on a separate track rather than as 3D text inside the Canvas. HTML text is sharper, cheaper, and supports the existing Google Fonts pipeline.
### Worked reference
A known-good minimal overlay lives at `tests/fixtures/overlays/three-cube.jsx` — it's the same file the render smoke test (`tests/test_render_three.py`) uses. Copy from it when starting a new 3D overlay; everything in it is checked end-to-end by CI.
### Bundle weight
Three.js + r3f add ~250 KB to an overlay segment's bundle after esbuild tree-shakes. Overlays that don't use `<Canvas>` pay zero cost. Don't reach for Three "just in case" — use it only when the result genuinely needs 3D.
---
## 2D Canvas
For plain 2D drawing HTML/CSS can't do — per-pixel effects, arbitrary paths, `drawImage` compositing — overlay JSX can drive a `CanvasRenderingContext2D` via `useCanvas2DFrame`. Unlike Three.js, this needs no library and behaves identically in preview and render — there's no internal render loop to disable.
```jsx
export default function CanvasExample() {
const draw = (ctx, { frame, fps, duration }) => {
ctx.fillStyle = '#EFE3CE'
ctx.fillRect(0, 0, 1080, 1080)
const x = interpolate(frame, [0, duration], [0, 1080 - 120])
ctx.fillStyle = '#3b82f6'
ctx.fillRect(x, 480, 120, 120)
}
const ref = useCanvas2DFrame(draw, frame, fps, duration)
return <canvas ref={ref} width={1080} height={1080} style={{ position: 'absolute', inset: 0 }} />
}
```
**`useCanvas2DFrame(draw, frame, fps, duration)` — always pass `frame`/`fps`/`duration` explicitly**, the same as `interpolate`/`spring`. It returns a ref to attach to a `<canvas>` element; `draw(ctx, { frame, fps, duration })` runs synchronously every time that ref is attached, which happens on every frame.
**Draw the complete frame from scratch every call** — same rule as everywhere else in overlay JSX: same `frame` in, same pixels out. Don't rely on anything painted by a previous call still being there (start with a fill/clear, as in the example above).
**One `<canvas>` per `useCanvas2DFrame` call.** Each call returns its own ref; don't share one ref across multiple canvases or call the hook conditionally.
---
## Charts / Recharts
Overlay JSX can use SVG-based charts via [Recharts](https://recharts.org). Available globals: `BarChart`, `Bar`, `LineChart`, `Line`, `PieChart`, `Pie`, `Cell`, `XAxis`, `YAxis`, `CartesianGrid`, `Tooltip`, `Legend`, `ResponsiveContainer`.
### Carousel-only (for now)
Charts work in carousel slides today. Video overlays don't have a chart story yet — see follow-up plans. The carousel renderer screenshots SVG directly, so no special contract is required.
### Non-negotiable rule: disable animations
Set `isAnimationActive={false}` on every chart primitive (`<Bar>`, `<Line>`, `<Pie>`, etc.). Recharts animates by default; without this, the carousel renderer captures a mid-animation frame and the chart looks half-drawn. If you see fuzzy ticks or a half-faded legend in the rendered PNG, also pass `isAnimationActive={false}` to `<XAxis>` / `<YAxis>` / `<Legend>` defensively.
### Size charts explicitly — don't use `<ResponsiveContainer>`
`<ResponsiveContainer>` measures via `ResizeObserver`, which is asynchronous; the Puppeteer screenshot can fire before the first observer callback, producing a blank chart. Instead, take `boxWidth` / `boxHeight` as props (the slide wrapper passes them in automatically) and use them as explicit `width` / `height` on the chart root: `<BarChart width={innerW} height={innerH} ...>`.
### Stick to bar / line / pie for v1
Three system overlay templates ship today: `bar-chart`, `line-chart`, `pie-chart`. For one-off data viz, write a custom overlay using the same globals. For variants likely to be reused (area, scatter, donut-vs-pie variants beyond `innerRadius`), promote them to system overlays so they show up in the property panel for everyone.
---
## project.json item shape
Place overlay items in `tracks[1+]` in `project.json`. Each item must have `type: "overlay"` and a `src` path pointing to the JSX file. All custom data goes inside `props`.
```json
{
"tracks": [
{ "id": "trk-0", "items": [] },
{
"id": "trk-1",
"items": [
{
"id": "ov-hook",
"type": "overlay",
"src": "/abs/path/to/project/overlays/hook.jsx",
"start": 0.0,
"end": 3.0,
"props": {
"text": "She built an AI employee"
}
},
{
"id": "ov-logo",
"type": "overlay",
"src": "/abs/path/to/project/overlays/logo.jsx",
"start": 0.0,
"end": 999.0,
"props": {
"logoSrc": "/abs/path/to/project/assets/logo.png"
}
}
]
}
]
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | yes | Unique identifier within the track |
| `type` | yes | Always `"overlay"` for JSX overlays |
| `src` | yes | Absolute path to the JSX file |
| `start` | yes | Start time in output video (seconds) |
| `end` | yes | End time in output video (seconds) |
| `props` | no | Arbitrary data passed through to the component as the `props` global |
| `googleFonts` | no | Google Font families to load before render (e.g. `["Anton", "Playfair+Display:ital@1"]`). See Custom fonts section. |
**Use absolute paths for `src`.** Relative paths are resolved from `project.json` location, but absolute paths are unambiguous.
---
## Using assets
Assets (logos, images) are declared in `project.assets`. Reference them by passing their `src` path in `props`, then use it in the component:
```json
{
"id": "ov-logo",
"type": "overlay",
"src": "/path/to/overlays/logo.jsx",
"start": 0.0,
"end": 30.0,
"props": { "src": "/path/to/assets/logo.png" }
}
```
```jsx
// overlays/logo.jsx
const opacity = interpolate(frame, [0, Math.round(fps * 0.2)], [0, 1])
export default function Logo() {
return (
<img
src={props.src}
style={{
position: 'absolute', top: 40, right: 40,
width: 80, opacity,
}}
/>
)
}
```
Reference assets by their workspace path (e.g. `/abs/path/to/project/assets/logo.png`). How that path is resolved for preview or render is the interface's concern — the component always receives a usable URL for the path it was given.
---
## Live preview in the Overlays tab
The Montaj UI has an **Overlays** tab that gives a real-time animated preview of every overlay in the project — no render needed.
- Open the **Overlays** tab and select a project from the left panel to see its overlay list
- Select any overlay item to see it playing over the preview image at full animation fidelity
- **Live reload** — the preview automatically recompiles and restarts whenever you save a `.jsx` file; latency is typically under a second
- Use the **⏸ / ▶** button in the bottom-right of the preview to pause on a specific frame
- Asset paths passed via `props` (e.g. `logoSrc`, `src`) are proxied automatically — images and logos resolve correctly in the preview even though they are absolute local paths
Use this tab to validate motion, timing, and asset rendering before committing to a full render.
---
## Writing multiple overlays in parallel
When a workflow calls for several overlays, write them concurrently — each JSX file is independent.
1. Identify all overlays needed from the editing prompt and transcript
2. Write **every** JSX file first — author the whole set before sampling or persisting anything. Do **not** sample after each file; interleaving a Puppeteer sample between every write is slow and breaks your authoring flow.
3. **Sample them all in one batch pass at the end** (see "Verify your overlays fit the canvas")
4. Fix any overflow, then save the project (delta) adding all items to the overlay track in one update
---
## Authoring guidelines
- **Text size for static formats.** **Do NOT apply video text sizing to carousels, story panels, or other static formats** where the text is being *read* rather than *glanced at*, and where headlines run longer than a punchy hook. For carousels see skill `carousel` §6 (Typography). For other static formats, default to ~32–48px body, ~52–80px headline, and size down further as line length grows.
- **Avoid the bottom ~350px** — captions render here, and platform UI (TikTok progress bar, Instagram controls) sits in this zone. Use `bottom: 350` or higher, or anchor from the top instead.
- **Avoid the right ~200px** — TikTok/Instagram action buttons (like, comment, share) occupy the right edge. Keep text and icons within `right: 200` or use `left`-anchored layout.
- **Don't overlap** — avoid two overlays occupying the same screen region at the same time
- **Persist after writing** — save the project (delta) with the new overlay items added to the track
---
## Verify your overlays fit the canvas — one sample pass at the end
**Write all of your overlay JSX first. Then sample them in a single batch pass — do not sample after each file.** Per-file sampling stalls authoring and spins up a fresh Puppeteer process each time; one pass at the end over the finished set is faster and just as safe, since nothing downstream consumes an overlay until you save the project with the whole batch.
Once every JSX file is written, loop over them in one pass — for each JSX file, **run step `sample_overlay`** with args `{ overlay, out, measure: true, google_fonts, props }`:
- `overlay` — absolute path to the JSX file
- `out` — absolute path where the step writes the sample PNG (required)
- `google_fonts` — the overlay's declared `googleFonts` value (snake_case step arg)
- `props` — representative props for the overlay
Pass each overlay's declared `googleFonts` (and representative `props`) so the step measures with the real render-time font — see the Syne case study below.
The step renders the overlay through the same Puppeteer path the production renderer uses and returns:
```json
{
"pngPath": "/tmp/sample-check.png",
"measurements": {
"anyOverflow": false,
"texts": [...],
"viewport": { "w": 1080, "h": 1920 }
}
}
```
**`measurements.anyOverflow` is the go/no-go signal.** If it is `true`, at least one text element extends past the 1080×1920 design canvas and will be visually clipped in the final render. Inspect `measurements.texts[]` for per-element detail: `bbox` gives the element's position and dimensions, and `overflow.{left,right,top,bottom}` gives the pixel overshoot on each edge.
**Two categories of false positives to rule out before fixing:**
- **`transform: rotate()` or `transform: scale()`** — `getBoundingClientRect()` returns the axis-aligned bounding box of the transformed element, which is wider and/or taller than the un-rotated element. A 1000×100 banner rotated 45° reports a ~777×777 bbox and will look like it overflows even when it doesn't. Check the `transform` field on the flagged element. If it's not `matrix(1, 0, 0, 1, 0, 0)` (the identity), the overflow signal is unreliable for that element.
- **`clippingAncestor`** — an element flagged for overflow may be the child of a container with `overflow: hidden` (animation sections that clip entering/exiting elements use exactly this). If the `clippingAncestor` field is non-null, the element is intentionally clipped. Compute `intersect(elementBbox, clippingAncestor.bbox)` to check effective overflow if you want to be precise.
**Do not ship an overlay JSX until `anyOverflow` is false** — or until you have verified each overflowing element is covered by one of the two false-positive cases above.
### The Syne overflow case study — why the editor preview cannot tell you
When the Montaj editor preview shows a layout that fits, but the rendered video shows clipped text, the cause is almost always a **font-width mismatch**. The editor preview falls back to `sans-serif` when a Google Font is not installed locally. The renderer loads the overlay's declared `googleFonts` faithfully via the Google Fonts CDN before rendering frame 0.
Display fonts like **Syne 800** are 60–70% wider than typical `sans-serif` fallbacks at the same `px` size. Concrete example: `"RECURSIVE"` at `fontSize: 160` measures ~933 px wide in fallback `sans-serif`, but ~1594 px wide in Syne 800 — 514 px of right-edge overflow on a 1080-wide canvas. The editor looked fine; the render was completely clipped.
**Any overlay that declares a `googleFonts` entry must go through the end-of-authoring sample pass with `measure: true` and that same `googleFonts` spec, before you save the project with the batch.** The preview cannot tell you whether the text fits in the render. The sample can.