LowerThird
The name-and-role bar of interviews and talks: measured plates in the safe area that slide in and leave on their own.
Live preview needs WebGPU, which this browser doesn't offer. You're watching a recording, and the props are read-only. Open this page in a current Chrome, Edge or Safari to change them live.
Loading the live preview
Install
Copies the source into your project. It's yours from then on: edit it like any other file.
npx framefields add lower-thirdWrites one file (lower-third/index.ts) into your components folder. Then:
import { LowerThird } from "./components/lower-third/index.js";
LowerThird({ name: "Ada Lovelace", role: "Engineer" })Props
Plus the props every component takes: at, duration, hold,enter, exit, x, y, width, height,theme, seed and customize.
| Prop | Type | Default |
|---|---|---|
namerequired | string | |
role | string | |
layout | "bar" | "stacked" | "tag" | "line" | "bar" |
side | "left" | "right" | "left" |
safeArea | "portrait" | "landscape" | { x, y, width, height } | |
stay | boolean | false |
Stay on screen instead of leaving after hold. | ||
accent | string | |
plate | string | |
nameSize | number | |
fontFamily | string | |
Cues
Frames other layers can key off, on the returned node: node.cues.in[0].frame. Change the props and everything keyed to a cue moves with it.
inout
Parts
The layers customize receives by name, to restyle or animate one piece without forking the component.
plateedgename-platerole-platetaglinetext
Source
What framefields add lower-third writes for lower-third/index.ts.
Show 473 lines
/** * LowerThird: the name-and-role bar of interviews and talks. Plates are * measured to their text, placed in the safe area's bottom corner, slide in * through their own box, and leave on their own after `hold`. * * Coordinates are the frame's: the root covers the video (default * the composition `canvas`, or the given width / height) from 0, 0. */import type { LayoutNode } from "framefields";import { type Animatable, bindComponent, defineComponent, elevate, KeyBook, parseColor, type Rect, safeArea, surfaceFill, themeFrames, z,} from "framefields/components";
export const LOWER_THIRD_LAYOUTS = ["bar", "stacked", "tag", "line"] as const;
/** Cap height as a share of the font size (Inter and most UI sans faces). */const CAP_HEIGHT = 0.72;export type LowerThirdLayout = (typeof LOWER_THIRD_LAYOUTS)[number];
export interface LowerThirdCues { [name: string]: ReadonlyArray<{ frame: number }>; /** Fully on screen. */ in: ReadonlyArray<{ frame: number }>; /** Starts to leave (empty with `stay`). */ out: ReadonlyArray<{ frame: number }>;}
export const lowerThird = /* @__PURE__ */ defineComponent({ name: "LowerThird", props: () => ({ name: z.string().min(1), role: z.string().optional(), layout: z.enum(LOWER_THIRD_LAYOUTS).default("bar"), side: z.enum(["left", "right"]).default("left"), safeArea: z .union([ z.enum(["portrait", "landscape"]), z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number(), }), ]) .optional(), /** Stay on screen instead of leaving after `hold`. */ stay: z.boolean().default(false), accent: z.string().optional(), plate: z.string().optional(), nameSize: z.number().positive().optional(), fontFamily: z.string().optional(), }), defaults: { enter: false, exit: false, hold: 90 }, build(ctx, props) { const { host, theme, fps } = ctx; const s = theme.scale; const W = ctx.requested.width ?? ctx.canvas.width; const H = ctx.requested.height ?? ctx.canvas.height; const safe = safeArea({ width: W, height: H }, props.safeArea); const fontFamily = props.fontFamily ?? theme.font.display; const nameSize = props.nameSize ?? 44 * s; const roleSize = nameSize * 0.62; const accent = props.accent ?? theme.color.accent; const plateColor = props.plate ?? theme.color.surface; const right = props.side === "right"; const base = themeFrames(theme, "base", fps); const fast = themeFrames(theme, "fast", fps); const enter = theme.motion.ease.enter;
const nameM = host.measure(props.name, { fontFamily, fontSize: nameSize, fontWeight: theme.weight.bold, }); const roleM = props.role ? host.measure(props.role, { fontFamily, fontSize: roleSize, fontWeight: theme.weight.medium, }) : null; const padX = nameSize * 0.55; const padY = nameSize * 0.36; const children: LayoutNode[] = []; const parts: Record<string, Animatable<LayoutNode>> = {}; /** Keys per node and prop, merged into one track each at the end. */ const book = new KeyBook<Animatable<LayoutNode>>(); const key = book.key.bind(book);
const text = ( id: string, content: string, size: number, weight: number, fill: string, x: number, y: number, ) => host.text(content, { id: ctx.partId(id), position: "absolute", x, y, fontFamily, fontSize: size, fontWeight: weight, fill, });
/** * A plate that slides in through its own box: the background travels * in from the side, its text rises a beat later. Returns the timing. */ const plate = ( id: string, rect: Rect, background: string, radius: number, content: Array<Animatable<LayoutNode>>, at: number, ) => { const fill = host.box({ id: ctx.partId(`${id}-fill`), position: "absolute", x: 0, y: 0, width: rect.width, height: rect.height, background: surfaceFill(background, theme), borderRadius: radius, // The slide is fast: a little smear sells it. motionBlurShutter: 90, }); const travel = rect.width * (right ? 1 : -1); const slide = Math.round(base * 1.1); key(fill, "x", [ [at, travel], [at + slide, 0, enter], ]); const textAt = at + Math.round(fast * 0.7); for (const node of content) { const y0 = (node as unknown as { y: number }).y; key(node, "y", [ [textAt, y0 + rect.height * 0.5], [textAt + base, y0, enter], ]); key(node, "opacity", [ [textAt, 0], [textAt + Math.round(base * 0.6), 1, "power2.out"], ]); } const clip = host.box({ id: ctx.partId(id), position: "absolute", ...rect, overflow: "hidden", borderRadius: radius, children: [fill, ...content], }); // A clear plate (the `line` layout) has nothing to lift. if (parseColor(background)?.[3] !== 0) elevate(host, clip, theme, "low"); children.push(clip); parts[id] = clip; return { clip, fill, content, travel, settled: Math.max(at + slide, textAt + base), }; };
// ── Layouts ─────────────────────────────────────────────────────── const bottom = safe.y + safe.height; const xFor = (width: number) => right ? safe.x + safe.width - width : safe.x; const plates: Array<ReturnType<typeof plate>> = []; let accentLine: Animatable<LayoutNode> | null = null; const lineW = Math.max(3, nameSize * 0.09);
switch (props.layout) { case "bar": { const edge = Math.max(4, nameSize * 0.14); const innerW = Math.max(nameM.width, roleM?.width ?? 0); const h = padY * 2 + nameM.height + (roleM ? roleM.height * 0.95 : 0); const w = innerW + padX * 2 + edge; const rect = { x: xFor(w), y: bottom - h, width: w, height: h }; const tx = right ? padX : padX + edge; const content = [ text( "name", props.name, nameSize, theme.weight.bold, theme.color.ink, tx, padY, ), ]; if (props.role && roleM) content.push( text( "role", props.role, roleSize, theme.weight.medium, theme.color.muted, tx, padY + nameM.height * 0.95, ), ); const p = plate("plate", rect, plateColor, theme.radius.sm, content, 0); const strip = host.box({ id: ctx.partId("edge"), position: "absolute", x: right ? rect.x + rect.width - edge : rect.x, y: rect.y + rect.height, width: edge, height: 0, background: accent, borderRadius: theme.radius.sm, }); const grow = Math.round(base * 0.8); key(strip, "height", [ [0, 0], [grow, rect.height, enter], ]); key(strip, "y", [ [0, rect.y + rect.height], [grow, rect.y, enter], ]); children.push(strip); parts.edge = strip; plates.push(p); break; } case "stacked": { const nameW = nameM.width + padX * 2; const nameH = nameM.height + padY * 2; const roleH = roleM ? roleM.height + padY * 1.2 : 0; const roleW = roleM ? roleM.width + padX * 1.6 : 0; const inset = nameSize * 0.4; const total = nameH + (roleM ? roleH * 0.9 : 0); const top = bottom - total; plates.push( plate( "name-plate", { x: xFor(nameW), y: top, width: nameW, height: nameH }, plateColor, theme.radius.sm, [ text( "name", props.name, nameSize, theme.weight.bold, theme.color.ink, padX, padY, ), ], 0, ), ); if (props.role && roleM) plates.push( plate( "role-plate", { x: right ? xFor(roleW) - inset : xFor(roleW) + inset, y: top + nameH - roleH * 0.1, width: roleW, height: roleH, }, accent, theme.radius.sm, [ text( "role", props.role, roleSize, theme.weight.semibold, theme.color.bg, padX * 0.8, padY * 0.6, ), ], Math.round(fast * 0.8), ), ); break; } case "tag": { // One line, one node: bold name, a dot, the role a size down. const sep = " · "; const sepW = props.role ? host.measure(sep, { fontFamily, fontSize: roleSize }).width : 0; const h = nameM.height + padY * 1.4; const w = nameM.width + (roleM ? sepW + roleM.width : 0) + h; const line = host.text( props.role ? `${props.name}${sep}${props.role}` : props.name, { id: ctx.partId("name"), position: "absolute", x: h / 2, y: padY * 0.7, fontFamily, fontSize: nameSize, fill: theme.color.ink, spans: [ { text: props.name, fontWeight: theme.weight.bold, fill: theme.color.ink, }, ...(props.role ? [ { text: `${sep}${props.role}`, fontSize: roleSize, fontWeight: theme.weight.medium, fill: theme.color.muted, // Centred on the name's capitals, not on its baseline: one // line in a pill reads as aligned when the caps share a middle. baselineShift: -(nameSize - roleSize) * CAP_HEIGHT * 0.5, }, ] : []), ], }, ); plates.push( plate( "tag", { x: xFor(w), y: bottom - h, width: w, height: h }, plateColor, theme.radius.pill, [line], 0, ), ); break; } case "line": { const innerW = Math.max(nameM.width, roleM?.width ?? 0); const h = nameM.height + (roleM ? roleM.height : 0); const gap = nameSize * 0.4; const w = innerW + gap + lineW; const x = xFor(w); const lineX = right ? x + w - lineW : x; const tx = right ? 0 : lineW + gap; accentLine = host.box({ id: ctx.partId("line"), position: "absolute", x: lineX, y: bottom, width: lineW, height: 0, background: accent, borderRadius: lineW / 2, }); const grow = Math.round(base * 1.1); key(accentLine, "height", [ [0, 0], [grow, h, enter], ]); key(accentLine, "y", [ [0, bottom], [grow, bottom - h, enter], ]); children.push(accentLine); parts.line = accentLine; const content = [ text( "name", props.name, nameSize, theme.weight.bold, theme.color.ink, tx, 0, ), ]; if (props.role && roleM) content.push( text( "role", props.role, roleSize, theme.weight.medium, theme.color.muted, tx, nameM.height, ), ); // No plate: the text rises through a clear box. plates.push( plate( "text", { x, y: bottom - h, width: w, height: h }, "rgba(0,0,0,0)", 0, content, Math.round(fast * 0.6), ), ); break; } }
const settled = Math.max(...plates.map((p) => p.settled)); // ── Out: text first, then the plates slide back the way they came ── let afterHold = 0; const outAt = settled + ctx.hold; if (!props.stay) { const fadeOut = Math.round(fast * 0.9); const slide = Math.round(base * 0.9); plates.forEach((p, k) => { const at = outAt + k * Math.round(fast * 0.4); for (const node of p.content) key(node, "opacity", [ [at, 1], [at + fadeOut, 0, "power2.in"], ]); key(p.fill, "x", [ [at + fadeOut - 2, 0], [at + fadeOut - 2 + slide, p.travel, theme.motion.ease.exit], ]); afterHold = Math.max(afterHold, at + fadeOut - 2 + slide - outAt); }); for (const node of [accentLine, parts.edge]) if (node) key(node, "opacity", [ [outAt, 1], [outAt + afterHold, 0, "power2.in"], ]); } book.apply();
return { children, width: W, height: H, duration: settled, openEnded: props.stay, afterHold, origin: [0, 0], cues: { in: [{ frame: settled }], out: props.stay ? [] : [{ frame: outAt }], } as LowerThirdCues, parts, }; },});
/** * The name-and-role bar of interviews and talks: measured plates in the safe * area that slide in and leave on their own. */export const LowerThird = /* @__PURE__ */ bindComponent(lowerThird);