Animated lower third
After this recipe you can put an animated lower third over any clip: a card with an accent bar, a name and a role, which slides in, holds, and slides out. The card is a layout generator, so its box grows with the text and the same document draws the same card in the browser and in a native export.
The project
Section titled “The project”{ "schema_version": 1, "id": "lower-third", "name": "Lower third", "settings": { "width": 1280, "height": 720, "frame_rate": { "num": 30, "den": 1 }, "sample_rate": 48000 }, "sources": [ { "id": "platform", "locator": "asset://platform", "kind": "image" }, { "id": "third", "kind": "generated", "generator": { "type": "layout", "root": { "flex_direction": "column", "justify_content": "end", "align_items": "start", "padding": { "mode": "constant", "value": { "top": 0.06, "right": 0.06, "bottom": 0.08, "left": 0.06 } }, "font_family": "Hanken Grotesk", "children": [ { "flex_direction": "row", "gap": { "mode": "constant", "value": 0.016 }, "padding": { "mode": "constant", "value": { "top": 0.022, "right": 0.03, "bottom": 0.022, "left": 0.022 } }, "background": { "mode": "constant", "value": { "type": "solid", "color": { "red": 0.04, "green": 0.05, "blue": 0.09, "alpha": 0.9 } } }, "border_radius": { "mode": "constant", "value": 0.012 }, "max_width": { "mode": "constant", "value": "60%" }, "opacity": { "mode": "keyframes", "keyframes": [ { "time": 0, "value": 0, "interpolation": { "type": "cubic_bezier", "x1": 0.2, "y1": 0, "x2": 0, "y2": 1 } }, { "time": 60000, "value": 1, "interpolation": { "type": "hold" } }, { "time": 408000, "value": 1, "interpolation": { "type": "cubic_bezier", "x1": 0.4, "y1": 0, "x2": 1, "y2": 1 } }, { "time": 468000, "value": 0, "interpolation": { "type": "hold" } } ] }, "translate": { "mode": "keyframes", "keyframes": [ { "time": 0, "value": { "x": -0.04, "y": 0 }, "interpolation": { "type": "cubic_bezier", "x1": 0.2, "y1": 0, "x2": 0, "y2": 1 } }, { "time": 60000, "value": { "x": 0, "y": 0 }, "interpolation": { "type": "hold" } }, { "time": 408000, "value": { "x": 0, "y": 0 }, "interpolation": { "type": "cubic_bezier", "x1": 0.4, "y1": 0, "x2": 1, "y2": 1 } }, { "time": 468000, "value": { "x": -0.04, "y": 0 }, "interpolation": { "type": "hold" } } ] }, "children": [ { "width": { "mode": "constant", "value": 0.01 }, "flex_shrink": { "mode": "constant", "value": 0 }, "background": { "mode": "constant", "value": { "type": "solid", "color": { "red": 0.17, "green": 0.36, "blue": 0.99, "alpha": 1 } } }, "border_radius": { "mode": "constant", "value": 0.005 } }, { "flex_direction": "column", "gap": { "mode": "constant", "value": 0.006 }, "children": [ { "text": { "mode": "constant", "value": "Ada Lovelace" }, "font_size": { "mode": "constant", "value": 0.07 } }, { "text": { "mode": "constant", "value": "Wrote the first published algorithm, 1843" }, "font_size": { "mode": "constant", "value": 0.04 }, "fill": { "red": 0.8, "green": 0.82, "blue": 0.86, "alpha": 1 } } ] } ] } ] } } } ], "timeline": { "tracks": [ { "id": "back", "name": "Picture", "kind": "video", "clips": [ { "id": "photo", "source_id": "platform", "timeline_range": { "start": 0, "end": 480000 }, "source_range": { "start": 0, "end": 480000 } } ], "transitions": [] }, { "id": "titles", "name": "Lower third", "kind": "video", "clips": [ { "id": "third-clip", "source_id": "third", "timeline_range": { "start": 0, "end": 480000 }, "source_range": { "start": 0, "end": 480000 } } ], "transitions": [] } ] }}How it works
Section titled “How it works”The project has two sources and two tracks. The photo is on the back track, and the lower third is a generated source on the titles track above it, so it draws on top.
The generator. The third source has kind: "generated" and a layout generator. A layout’s picture is the size of the canvas, and its root box is that picture. The root places its content with CSS flexbox: flex_direction: "column" with justify_content: "end" puts the card at the bottom, and align_items: "start" keeps it on the left. The root’s padding keeps it off the edges. Every length is a fraction of the picture’s shorter side, so 0.06 is 6% of the height on a 16:9 canvas and of the width on a 9:16 one.
The card. The root’s only child is a row (flex_direction: "row") with a gap, its own padding, a translucent background and a border_radius. max_width: "60%" stops a long role from running across the whole frame: the text wraps instead, and the card grows taller.
The accent bar and the text. The row holds a 0.01-wide box with a blue background, which is the bar, and a column with two text boxes. flex_shrink: 0 keeps the bar from being squeezed when the text is long. The bar has no height of its own: it stretches to the row’s height, as a flex item does in CSS. font_family is set on the root and inherited by both texts. The role sets its own font_size and a grey fill.
The animation. opacity and translate on the card are drawn after layout, so they move the card without moving anything else. Each is a list of keyframes. A keyframe’s interpolation shapes the segment that starts at it:
| Time (ticks) | Seconds | Opacity | Translate | Interpolation |
|---|---|---|---|---|
| 0 | 0 | 0 | x −0.04 | ease out (cubic_bezier 0.2, 0, 0, 1) |
| 60000 | 0.5 | 1 | 0 | hold until the next key |
| 408000 | 3.4 | 1 | 0 | ease in (cubic_bezier 0.4, 0, 1, 1) |
| 468000 | 3.9 | 0 | x −0.04 | hold |
Times are ticks, 120,000 to the second. A generator’s keyframes are in picture time: the source time the clip shows. This clip starts at 0 and reads its source from 0, so picture time and timeline time are the same here. If you move the clip to 10 s, the card still slides in when the clip starts.
Export it
Section titled “Export it”The photo source has the id platform, so give the export a file for it with --asset. The text draws with the font files you pass. Pass the same font the preview uses, so the text breaks the same way.
alpha2-host export --project lower-third.json \ --asset platform=platform.jpg \ --font HankenGrotesk.ttf \ --out lower-third.mp4import { readFile } from "node:fs/promises";import { exportProject } from "@alpha2-sh/node";
const project = JSON.parse(await readFile("lower-third.json", "utf8"));await exportProject({ project, assets: { platform: "platform.jpg" }, fonts: ["HankenGrotesk.ttf"], startTicks: 0, endTicks: 480_000, // 4 s frameRate: { num: 30, den: 1 }, output: "lower-third.mp4",});Without --end-ticks, the CLI exports the whole timeline. @alpha2-sh/node needs the range.
See also
Section titled “See also”- Layout: flexbox and grid boxes, units and text.
- Animation: keyframes, interpolation and time bases.
- Generators: fills, text, shapes and layouts as sources.
- Layout schema: every field of a layout box.
