Staggered lines
Two line hierarchy with independent entrance timing.
Customize
import {Scene, Video} from "odori";
import {StaggeredLines} from "../components/staggered-lines/staggered-lines";
<Video>
<Scene id="staggered-lines" duration="4s">
<StaggeredLines
lines={[
"One workflow.",
"Every launch."
]}
detail="From first scene to final render."
align="left"
stagger={10}
/>
</Scene>
</Video>Installation
pnpm odori add component staggered-linesnpx odori add component staggered-linesbunx odori add component staggered-linesEdit the source in videos/components/staggered-lines/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/staggered-lines/staggered-lines.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type StaggeredLinesProps = {
/** Each line enters on its own beat, in order. */
lines: string[];
detail?: string;
align?: "left" | "center";
/** Frames between one line and the next. */
stagger?: number;
};
/**
* Two or three lines with independent entrance timing, so a statement builds
* its own hierarchy: the first line lands, the rest answer it.
*/
export const StaggeredLines = ({lines, detail, align = "left", stagger = 10}: StaggeredLinesProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
return (
<Fill
style={{
alignItems: align === "center" ? "center" : "flex-start",
gap: 8 * scale,
justifyContent: "center",
padding: `${120 * scale}px ${150 * scale}px`,
textAlign: align,
}}
>
{lines.map((line, index) => {
const delay = 6 + index * stagger;
const progress = interpolate(frame, [delay, delay + 26], [0, 1], {easing: Easing.standard});
const leading = index === 0;
return (
<div key={`${line}-${index}`} style={{overflow: "hidden", paddingBottom: 12 * scale}}>
<span
style={{
color: leading ? brand.colors.foreground : brand.colors.muted,
display: "inline-block",
fontSize: (leading ? 112 : 92) * scale,
fontWeight: leading ? 570 : 460,
letterSpacing: "-0.045em",
lineHeight: 1.05,
opacity: progress,
transform: `translateY(${(1 - progress) * 70 * scale}px)`,
}}
>
{line}
</span>
</div>
);
})}
{detail ? (
<div
style={{
color: brand.colors.muted,
fontSize: 34 * scale,
marginTop: 20 * scale,
opacity: interpolate(frame, [6 + lines.length * stagger, 6 + lines.length * stagger + 20], [0, 1], {
easing: Easing.standard,
}),
}}
>
{detail}
</div>
) : null}
</Fill>
);
};
videos/components/staggered-lines/staggered-lines.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {StaggeredLines} from "./staggered-lines";
export default defineComponentPreview({
title: "Staggered lines",
category: "Typography/Titles",
description: "Two line hierarchy with independent entrance timing.",
component: StaggeredLines,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
align: {type: "select", defaultValue: "left", options: ["left", "center"]},
stagger: {type: "number", defaultValue: 10, min: 4, max: 30},
detail: {type: "text", defaultValue: "From first scene to final render."},
},
examples: [
{name: "Default", props: {lines: ["One workflow.", "Every launch."]}},
{name: "Centered", props: {lines: ["Author the story.", "Render the proof."], align: "center"}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {StaggeredLines} from "../components/staggered-lines/staggered-lines";
<Video>
<Scene id="staggered-lines" duration="4s">
<StaggeredLines
lines={[
"One workflow.",
"Every launch."
]}
detail="From first scene to final render."
align="left"
stagger={10}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Typography
- Recommended duration
- 120 frames · 4s
- Minimum duration
- 48 frames · 1.6s
- Entrance
- 32 frames
- Exit
- 10 frames
- Reduced motion
- lines fade in together
- Requires
- sans font
- Content limits
- lines ≤ 3, lineLength ≤ 42
Props
| Prop | Type | Default | Required |
|---|---|---|---|
linesEach line enters on its own beat, in order. | string[] | — | Yes |
detail | string | — | — |
align | "left" | "center" | "left" | — |
staggerFrames between one line and the next. | number | 10 | — |
Related components
All componentsCaptionsFrame-accurate caption cues for sound-off playback.Character riseCharacters rise with a crisp per-letter stagger.Draw titleAn outline draws across the line, then fills solid.Flap boardA split-flap board clatters through to the target string.Focus inWords settle from a soft blur into a crisp line.Glitch textA title that arrives through brief seeded interference bursts.