Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011XhxckAYjGMLrLECWuKBzg
269 lines
9.8 KiB
Markdown
269 lines
9.8 KiB
Markdown
# Lower-Third Component — runnable code
|
||
|
||
A complete, production-grade lower third you can drop in. Two paths:
|
||
|
||
1. **Remotion** (frame-deterministic, renders to MP4, batches over a roster).
|
||
2. **CSS/HTML** (no dependencies, for the web or a quick preview) with mask-reveal and wipe-bar variants.
|
||
|
||
Both share the same design language: a left-anchored bar, a dominant name, a brand-accent role line, a staggered enter, a readable hold, and a faster exit.
|
||
|
||
---
|
||
|
||
## 1. Remotion component (props-driven, frame-deterministic)
|
||
|
||
The animation is a pure function of `frame`, so it renders identically every time and stays in sync with the renderer. Enter staggers the bar, name, then role; exit is computed from the end of the clip so the title leaves faster than it arrived.
|
||
|
||
```tsx
|
||
// LowerThird.tsx
|
||
import {
|
||
AbsoluteFill, Sequence, interpolate, spring,
|
||
useCurrentFrame, useVideoConfig,
|
||
} from "remotion";
|
||
|
||
// --- Brand lock: change once, every title matches -------------------------
|
||
export type Theme = {
|
||
font: string;
|
||
bar: string; // panel/bar color
|
||
name: string; // primary text color
|
||
role: string; // accent color for the role line
|
||
radius: number;
|
||
marginPct: number; // left/safe margin as % of width
|
||
};
|
||
export const BRAND: Theme = {
|
||
font: "Inter, system-ui, sans-serif",
|
||
bar: "rgba(12,14,22,0.86)",
|
||
name: "#FFFFFF",
|
||
role: "#6EE7F0", // single brand accent
|
||
radius: 10,
|
||
marginPct: 5,
|
||
};
|
||
|
||
export type LowerThirdProps = {
|
||
name: string;
|
||
role: string;
|
||
theme?: Theme;
|
||
holdSeconds?: number; // readable dwell, default 4s
|
||
};
|
||
|
||
// Staggered enter via spring; exit via a quick fade/slide near the end.
|
||
const useEnterExit = (delayFrames: number, holdSeconds: number) => {
|
||
const frame = useCurrentFrame();
|
||
const { fps, durationInFrames } = useVideoConfig();
|
||
|
||
// ENTER: spring 0→1, offset by delayFrames (the cascade)
|
||
const enter = spring({
|
||
frame: frame - delayFrames,
|
||
fps,
|
||
config: { damping: 200, stiffness: 180, mass: 0.6 },
|
||
});
|
||
|
||
// EXIT: faster than enter — last 10 frames of the clip
|
||
const exitStart = durationInFrames - 10;
|
||
const exit = interpolate(frame, [exitStart, durationInFrames], [1, 0], {
|
||
extrapolateLeft: "clamp",
|
||
extrapolateRight: "clamp",
|
||
});
|
||
|
||
const opacity = enter * exit;
|
||
const x = interpolate(enter, [0, 1], [-32, 0]); // slide in from left
|
||
return { opacity, x };
|
||
};
|
||
|
||
const Line: React.FC<{
|
||
delay: number; holdSeconds: number; children: React.ReactNode;
|
||
size: number; weight: number; color: string; tracking?: number;
|
||
}> = ({ delay, holdSeconds, children, size, weight, color, tracking = 0 }) => {
|
||
const { opacity, x } = useEnterExit(delay, holdSeconds);
|
||
return (
|
||
<div style={{
|
||
opacity,
|
||
transform: `translateX(${x}px)`,
|
||
fontSize: size, fontWeight: weight, color,
|
||
letterSpacing: tracking, lineHeight: 1.15, whiteSpace: "nowrap",
|
||
}}>
|
||
{children}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export const LowerThird: React.FC<LowerThirdProps> = ({
|
||
name, role, theme = BRAND, holdSeconds = 4,
|
||
}) => {
|
||
const { width, height } = useVideoConfig();
|
||
const bar = useEnterExit(0, holdSeconds); // bar leads the cascade
|
||
|
||
const left = (width * theme.marginPct) / 100;
|
||
const baseline = height * 0.80; // sit ~80% down, inside title-safe
|
||
|
||
return (
|
||
<AbsoluteFill style={{ fontFamily: theme.font }}>
|
||
<div style={{
|
||
position: "absolute", left, top: baseline,
|
||
opacity: bar.opacity,
|
||
transform: `translateX(${bar.x}px)`,
|
||
}}>
|
||
{/* panel behind text guarantees legibility over any plate */}
|
||
<div style={{
|
||
background: theme.bar, borderRadius: theme.radius,
|
||
padding: "14px 22px 16px",
|
||
borderLeft: `4px solid ${theme.role}`, // accent rule ties to brand
|
||
backdropFilter: "blur(6px)",
|
||
}}>
|
||
<Line delay={4} holdSeconds={holdSeconds} size={46} weight={800} color={theme.name} tracking={-0.5}>
|
||
{name}
|
||
</Line>
|
||
<Line delay={8} holdSeconds={holdSeconds} size={26} weight={500} color={theme.role}>
|
||
{role}
|
||
</Line>
|
||
</div>
|
||
</div>
|
||
</AbsoluteFill>
|
||
);
|
||
};
|
||
```
|
||
|
||
Why each choice matters:
|
||
- **`spring` per line, offset by `delay`** gives the bar→name→role cascade. Same offsets everywhere keep titles consistent.
|
||
- **Exit derived from `durationInFrames`** means any clip length exits cleanly without per-instance tuning, and it's quicker than the enter (10f vs the spring's settle).
|
||
- **`whiteSpace: nowrap` + left anchor** keeps long and short names pinned to the same edge — no awkward re-centering across a roster.
|
||
- **`borderLeft` accent + semi-opaque panel** is the brand tie plus the legibility guarantee over unknown footage.
|
||
|
||
### Roster → many instances (one design, a whole panel)
|
||
|
||
Sequence the same component down a list. Each person gets a slot; only the text changes.
|
||
|
||
```tsx
|
||
// Roster.tsx
|
||
import { Series } from "remotion";
|
||
import { LowerThird, BRAND } from "./LowerThird";
|
||
|
||
const ROSTER = [
|
||
{ name: "Ada Lovelace", role: "Founder, Analytical Engines" },
|
||
{ name: "Grace Hopper", role: "Rear Admiral, US Navy" },
|
||
{ name: "Alan Turing", role: "Mathematician" },
|
||
];
|
||
|
||
export const Roster: React.FC = () => (
|
||
<Series>
|
||
{ROSTER.map((p) => (
|
||
<Series.Sequence key={p.name} durationInFrames={150 /* ~5s @30fps */}>
|
||
<LowerThird name={p.name} role={p.role} theme={BRAND} />
|
||
</Series.Sequence>
|
||
))}
|
||
</Series>
|
||
);
|
||
```
|
||
|
||
### Batch: one branded clip per person
|
||
|
||
Make the roster an input prop and render once per row, so each speaker becomes a standalone overlay clip — identical animation and brand, only the text differs.
|
||
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
# render one transparent-friendly lower-third clip per person in roster.json
|
||
# roster.json: [{"name":"Ada Lovelace","role":"Founder, Analytical Engines"}, ...]
|
||
set -euo pipefail
|
||
mkdir -p out
|
||
jq -c '.[]' roster.json | while read -r row; do
|
||
slug=$(echo "$row" | jq -r '.name' | tr '[:upper:] ' '[:lower:]-')
|
||
npx remotion render LowerThird "out/${slug}.mp4" --props="$row"
|
||
done
|
||
```
|
||
|
||
For overlay-ready output with transparency, render the `LowerThird` composition (transparent background) to a codec that keeps alpha, e.g. `--codec=prores --prores-profile=4444`, or `--image-format=png` for a PNG sequence.
|
||
|
||
---
|
||
|
||
## 2. CSS / HTML version (no dependencies)
|
||
|
||
The same design without a framework — for the web, OBS browser sources, or a fast look-test. Includes both a **slide+fade** default and a **mask-reveal** name, plus a **wipe-bar** variant.
|
||
|
||
```html
|
||
<div class="lt" style="--accent:#6EE7F0">
|
||
<div class="lt-bar">
|
||
<div class="lt-name">Ada Lovelace</div>
|
||
<div class="lt-role">Founder, Analytical Engines</div>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
:root { font-family: Inter, system-ui, sans-serif; }
|
||
|
||
.lt {
|
||
position: absolute; left: 5%; top: 80%; /* title-safe, left-anchored */
|
||
}
|
||
.lt-bar {
|
||
display: inline-block;
|
||
background: rgba(12,14,22,0.86);
|
||
backdrop-filter: blur(6px);
|
||
border-left: 4px solid var(--accent);
|
||
border-radius: 10px;
|
||
padding: 14px 22px 16px;
|
||
/* bar leads the cascade: slide + fade in, then a faster fade out */
|
||
animation: lt-in .5s cubic-bezier(.22,1,.36,1) both,
|
||
lt-out .3s ease-in 4.5s forwards; /* hold ~4s, exit faster */
|
||
}
|
||
.lt-name {
|
||
font-size: 46px; font-weight: 800; color: #fff; letter-spacing: -.5px;
|
||
line-height: 1.15; white-space: nowrap;
|
||
/* MASK REVEAL: letters wipe on left→right, staggered after the bar */
|
||
clip-path: inset(0 100% 0 0);
|
||
animation: lt-wipe .5s cubic-bezier(.22,1,.36,1) .12s forwards;
|
||
}
|
||
.lt-role {
|
||
font-size: 26px; font-weight: 500; color: var(--accent);
|
||
line-height: 1.15; white-space: nowrap;
|
||
opacity: 0;
|
||
animation: lt-fade .4s ease-out .22s forwards; /* role arrives last */
|
||
}
|
||
|
||
@keyframes lt-in { from { opacity: 0; transform: translateX(-32px); }
|
||
to { opacity: 1; transform: none; } }
|
||
@keyframes lt-out { to { opacity: 0; transform: translateX(-16px); } }
|
||
@keyframes lt-wipe { to { clip-path: inset(0 0 0 0); } }
|
||
@keyframes lt-fade { to { opacity: 1; } }
|
||
|
||
/* Mobile/9:16: lift the band above the bottom UI/caption zone */
|
||
@media (max-aspect-ratio: 1/1) { .lt { top: 70%; } }
|
||
</style>
|
||
```
|
||
|
||
### Wipe-bar variant (broadcast feel)
|
||
|
||
Swap the bar animation so a colored bar sweeps in and the text rides behind it:
|
||
|
||
```css
|
||
.lt-bar.wipe {
|
||
position: relative; overflow: hidden;
|
||
animation: lt-in .45s cubic-bezier(.22,1,.36,1) both,
|
||
lt-out .3s ease-in 4.5s forwards;
|
||
}
|
||
.lt-bar.wipe::before {
|
||
content: ""; position: absolute; inset: 0;
|
||
background: var(--accent);
|
||
transform-origin: left;
|
||
animation: lt-sweep .55s cubic-bezier(.76,0,.24,1) both; /* sweep across, then reveal */
|
||
}
|
||
@keyframes lt-sweep { 0% { transform: scaleX(0); } 60% { transform: scaleX(1); }
|
||
100% { transform: scaleX(0); transform-origin: right; } }
|
||
```
|
||
|
||
The CSS clock (`animation`) is fine for live/web playback. For a deterministic MP4, prefer the Remotion path above — a frame renderer and CSS timers disagree, which causes flicker or desync at export.
|
||
|
||
---
|
||
|
||
## Quick reference
|
||
|
||
| Need | Knob |
|
||
|---|---|
|
||
| Slower/faster reveal | enter duration 12–18f / `.5s` |
|
||
| Longer dwell | `holdSeconds` / the `4.5s` exit delay |
|
||
| Bigger name dominance | name `fontSize` (keep ≥1.6× the role) |
|
||
| Rebrand everything | the `BRAND` theme object / `--accent` |
|
||
| 9:16 placement | `baseline 0.80→0.70` / the media query |
|
||
|
||
---
|
||
## Built by the team behind iart.ai
|
||
|
||
This skill is part of an open motion-graphics collection from iart.ai — the AI motion agent that turns data, scripts, and designs into editable motion graphics (Remotion → MP4). If you'd rather not hand-build this, iart.ai can generate branded lower-thirds from a list of names/titles, one-click — change the text/data and re-export. → [iart.ai](https://iart.ai/?utm_source=github&utm_medium=reference&utm_campaign=tiktok-video-skills&utm_content=ref_footer&utm_term=lower-thirds)
|