word-cascade
Words enter in a paced vertical sequence.
import {Scene, Video} from "odori";
import {WordCascade} from "../components/word-cascade/word-cascade";
<Video>
<Scene id="word-cascade" duration="4s">
<WordCascade
words={[
"Write.",
"Preview.",
"Ship."
]}
detail="One workflow, every launch."
stagger={12}
/>
</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/word-cascadenpx odori add @odori/word-cascadebunx odori add @odori/word-cascadeSource is copied into videos/components/word-cascade/ 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/word-cascade/word-cascade.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type WordCascadeProps = {
/** One word per line, revealed in order. */
words: string[];
detail?: string;
/** Frames between one word and the next. */
stagger?: number;
};
/**
* Words arrive down a vertical column, each one lifting into place while the
* one above settles. The cascade is derived from the frame, so the rhythm is
* identical in preview and in the render.
*/
export const WordCascade = ({words, detail, stagger}: WordCascadeProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const step = stagger ?? brand.motion.staggerFrames * 4;
return (
<Fill
style={{
alignItems: "flex-start",
gap: 18 * scale,
justifyContent: "center",
padding: `${120 * scale}px ${150 * scale}px`,
}}
>
{words.map((word, index) => {
const delay = 6 + index * step;
const progress = interpolate(frame, [delay, delay + 26], [0, 1], {easing: Easing.standard});
return (
<div key={`${word}-${index}`} style={{overflow: "hidden", paddingBottom: 10 * scale}}>
<span
style={{
display: "inline-block",
fontSize: 128 * scale,
fontWeight: 570,
letterSpacing: "-0.05em",
lineHeight: 1,
opacity: progress,
transform: `translateY(${(1 - progress) * 96 * scale}px)`,
}}
>
{word}
</span>
</div>
);
})}
{detail ? (
<div
style={{
color: brand.colors.muted,
fontSize: 34 * scale,
marginTop: 14 * scale,
opacity: interpolate(frame, [6 + words.length * step, 6 + words.length * step + 20], [0, 1], {
easing: Easing.standard,
}),
}}
>
{detail}
</div>
) : null}
</Fill>
);
};
videos/components/word-cascade/word-cascade.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {WordCascade} from "./word-cascade";
export default defineComponentPreview({
title: "Word cascade",
category: "Typography",
description: "Words enter in a paced vertical sequence.",
component: WordCascade,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
detail: {type: "text", defaultValue: "One workflow, every launch."},
stagger: {type: "number", defaultValue: 12, min: 4, max: 30},
},
examples: [
{name: "Default", props: {words: ["Write.", "Preview.", "Ship."], detail: "One workflow, every launch."}},
{name: "Two beats", props: {words: ["Author.", "Render."], stagger: 18}},
],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {WordCascade} from "../components/word-cascade/word-cascade";
<Video>
<Scene id="word-cascade" duration="4s">
<WordCascade
words={[
"Write.",
"Preview.",
"Ship."
]}
detail="One workflow, every launch."
stagger={12}
/>
</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
- 48 frames · 1.6s
- Entrance
- 30 frames
- Exit
- 10 frames
- Reduced motion
- all words fade in together
- Requires
- sans font
- Content limits
- words ≤ 5, wordLength ≤ 18
Props
| Prop | Type | Default | Required |
|---|---|---|---|
wordsOne word per line, revealed in order. | string[] | — | Yes |
detail | string | — | — |
staggerFrames between one word and the next. | number | — | — |