title-reveal
A staggered, word-masked headline.
import {Scene, Video} from "odori";
import {TitleReveal} from "../components/title-reveal/title-reveal";
<Video>
<Scene id="title-reveal" duration="4s">
<TitleReveal
title="Ship the story."
detail="An independent React video framework."
align="center"
/>
</Scene>
</Video>The same runtime that renders the export. Scrub to any frame, switch format, and edit the fixture props.
Installation
pnpm odori add @odori/title-revealnpx odori add @odori/title-revealbunx odori add @odori/title-revealSource is copied into videos/components/title-reveal/ and belongs to your repository. odori diff compares it with upstream later; odori update applies the change you accept. It has no registry dependencies.
Copy and paste the following code into your project.
videos/components/title-reveal/title-reveal.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type TitleRevealProps = {
title: string;
detail?: string;
align?: "left" | "center";
};
/**
* A masked, word-staggered headline. Each word rises out of a clipped line
* box, so the reveal reads as typography rather than a fade.
*/
export const TitleReveal = ({title, detail, align = "center"}: TitleRevealProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const lines = title.split("\n");
let wordIndex = 0;
return (
<Fill
style={{
alignItems: align === "center" ? "center" : "flex-start",
gap: 34 * scale,
justifyContent: "center",
padding: `${120 * scale}px ${140 * scale}px`,
textAlign: align,
}}
>
<div style={{display: "grid", gap: 6 * scale, maxWidth: 1560 * scale}}>
{lines.map((line, lineNumber) => (
<div
key={`${line}-${lineNumber}`}
style={{
display: "flex",
flexWrap: "wrap",
gap: `0 ${26 * scale}px`,
justifyContent: align === "center" ? "center" : "flex-start",
overflow: "hidden",
paddingBottom: 12 * scale,
}}
>
{line.split(/\s+/).map((word) => {
const delay = 4 + wordIndex * brand.motion.staggerFrames;
wordIndex += 1;
const progress = interpolate(frame, [delay, delay + 24], [0, 1], {easing: Easing.standard});
return (
<span
key={`${word}-${wordIndex}`}
style={{
display: "inline-block",
fontSize: 116 * scale,
fontWeight: 560,
letterSpacing: "-0.045em",
lineHeight: 1.04,
opacity: progress,
transform: `translateY(${(1 - progress) * 78 * scale}px)`,
}}
>
{word}
</span>
);
})}
</div>
))}
</div>
{detail ? (
<div
style={{
color: brand.colors.muted,
fontSize: 36 * scale,
letterSpacing: "-0.015em",
maxWidth: 1100 * scale,
opacity: interpolate(frame, [16, 34], [0, 1], {easing: Easing.standard}),
transform: `translateY(${interpolate(frame, [16, 34], [14 * scale, 0], {easing: Easing.standard})}px)`,
}}
>
{detail}
</div>
) : null}
</Fill>
);
};
videos/components/title-reveal/title-reveal.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {TitleReveal} from "./title-reveal";
export default defineComponentPreview({
title: "Title reveal",
category: "Typography",
description: "A staggered, word-masked headline.",
component: TitleReveal,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
title: {type: "text", defaultValue: "Ship the story.", maxLength: 64},
detail: {type: "text", defaultValue: "An independent React video framework."},
align: {type: "select", defaultValue: "center", options: ["left", "center"]},
},
examples: [
{name: "Default", props: {title: "Ship the story."}},
{name: "Two lines", props: {title: "Build videos\nlike applications.", detail: "Author. Preview. Ship."}},
{name: "Long copy", props: {title: "Build videos like applications, not like exports.", align: "left"}},
],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {TitleReveal} from "../components/title-reveal/title-reveal";
<Video>
<Scene id="title-reveal" duration="4s">
<TitleReveal
title="Ship the story."
detail="An independent React video framework."
align="center"
/>
</Scene>
</Video>Nothing else to install.
The component imports only from odori, so there are no dependencies to add and no import paths to rewrite.
Timing contract
Every registry component declares how it behaves in time.odori test fails a cut that violates these.
- Family
- Typography
- Aspect ratios
- 16:9 · 9:16 · 1:1
- Recommended duration
- 120 frames · 4s
- Minimum duration
- 45 frames · 1.5s
- Entrance
- 20 frames
- Exit
- 10 frames
- Reduced motion
- fades without translation
- Requires
- sans font
- Content limits
- title ≤ 64
Props
| Prop | Type | Default | Required |
|---|---|---|---|
title | string | — | Yes |
detail | string | — | — |
align | "left" | "center" | "center" | — |