staggered-lines
Two line hierarchy with independent entrance timing.
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>The same runtime that renders the export. Scrub to any frame, switch format, and edit the fixture props.
Installation
pnpm odori add @odori/staggered-linesnpx odori add @odori/staggered-linesbunx odori add @odori/staggered-linesSource is copied into videos/components/staggered-lines/ 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/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",
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"}},
],
});
Use it in 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>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
- 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 | — |