timeline
Milestones revealed along a measured temporal axis.
import {Scene, Video} from "odori";
import {Timeline} from "../components/timeline/timeline";
<Video>
<Scene id="timeline" duration="5s">
<Timeline
milestones={[
{
label: "Idea",
detail: "one sentence"
},
{
label: "Draft",
detail: "scenes in place"
},
{
label: "Review",
detail: "contract tests"
},
{
label: "Launch",
detail: "one export"
}
]}
headline="From idea to launch"
/>
</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/timelinenpx odori add @odori/timelinebunx odori add @odori/timelineSource is copied into videos/components/timeline/ 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/timeline/timeline.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type Milestone = {label: string; detail?: string};
export type TimelineProps = {
milestones: Milestone[];
headline?: string;
};
/**
* Milestones along one measured axis. The axis draws first and the markers
* land on it in order, so the spacing reads as time rather than as a list.
*/
export const Timeline = ({milestones, headline}: TimelineProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const axis = interpolate(frame, [8, 44], [0, 1], {easing: Easing.standard});
return (
<Fill style={{gap: 60 * scale, justifyContent: "center", padding: `${110 * scale}px ${120 * scale}px`}}>
{headline ? (
<div style={{fontSize: 58 * scale, fontWeight: 560, letterSpacing: "-0.04em", opacity: interpolate(frame, [0, 16], [0, 1])}}>
{headline}
</div>
) : null}
<div style={{position: "relative"}}>
<div
style={{
background: brand.colors.border,
height: 2 * scale,
transform: `scaleX(${axis})`,
transformOrigin: "left",
width: "100%",
}}
/>
<div style={{display: "flex", justifyContent: "space-between", marginTop: -12 * scale}}>
{milestones.map((milestone, index) => {
const at = 24 + index * 12;
const enter = interpolate(frame, [at, at + 18], [0, 1], {easing: Easing.standard});
return (
<div key={milestone.label} style={{alignItems: "center", display: "grid", gap: 16 * scale, justifyItems: "center", opacity: enter}}>
<span
style={{
background: index === milestones.length - 1 ? brand.colors.accent : brand.colors.foreground,
borderRadius: 999,
height: 22 * scale,
transform: `scale(${0.6 + enter * 0.4})`,
width: 22 * scale,
}}
/>
<span style={{fontSize: 34 * scale, fontWeight: 540}}>{milestone.label}</span>
{milestone.detail ? (
<span style={{color: brand.colors.muted, fontSize: 26 * scale}}>{milestone.detail}</span>
) : null}
</div>
);
})}
</div>
</div>
</Fill>
);
};
videos/components/timeline/timeline.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Timeline} from "./timeline";
export default defineComponentPreview({
title: "Timeline",
category: "Narrative",
description: "Milestones revealed along a measured temporal axis.",
component: Timeline,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {headline: {type: "text", defaultValue: "From idea to launch"}},
examples: [
{
name: "Launch",
props: {
milestones: [
{label: "Idea", detail: "one sentence"},
{label: "Draft", detail: "scenes in place"},
{label: "Review", detail: "contract tests"},
{label: "Launch", detail: "one export"},
],
},
},
],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {Timeline} from "../components/timeline/timeline";
<Video>
<Scene id="timeline" duration="5s">
<Timeline
milestones={[
{
label: "Idea",
detail: "one sentence"
},
{
label: "Draft",
detail: "scenes in place"
},
{
label: "Review",
detail: "contract tests"
},
{
label: "Launch",
detail: "one export"
}
]}
headline="From idea to launch"
/>
</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
- Narrative
- Aspect ratios
- 16:9 · 1:1
- Recommended duration
- 150 frames · 5s
- Minimum duration
- 75 frames · 2.5s
- Entrance
- 72 frames
- Exit
- 0 frames
- Reduced motion
- the axis and markers appear at once
- Requires
- sans font
- Content limits
- milestones ≤ 5, labelLength ≤ 18
Props
| Prop | Type | Default | Required |
|---|---|---|---|
milestones | Milestone[] | — | Yes |
headline | string | — | — |