connection-story
A left-to-right pipeline of connected stages.
import {Scene, Video} from "odori";
import {ConnectionStory} from "../components/connection-story/connection-story";
<Video>
<Scene id="connection-story" duration="5s">
<ConnectionStory
nodes={[
{
label: "videos/",
detail: "source of truth"
},
{
label: "Studio",
detail: "instant preview"
},
{
label: "Export",
detail: "frozen manifest"
}
]}
headline="One definition, every render path."
/>
</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/connection-storynpx odori add @odori/connection-storybunx odori add @odori/connection-storySource is copied into videos/components/connection-story/ 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/connection-story/connection-story.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type ConnectionNode = {label: string; detail?: string};
export type ConnectionStoryProps = {
nodes: ConnectionNode[];
headline?: string;
};
/** Stages connect left to right as the frame advances. */
export const ConnectionStory = ({nodes, headline}: ConnectionStoryProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
return (
<Fill style={{alignItems: "center", gap: 76 * scale, justifyContent: "center", padding: 110 * scale}}>
{headline ? (
<div
style={{
fontSize: 58 * scale,
fontWeight: 540,
letterSpacing: "-0.04em",
opacity: interpolate(frame, [0, 16], [0, 1], {easing: Easing.standard}),
textAlign: "center",
transform: `translateY(${interpolate(frame, [0, 20], [16 * scale, 0], {easing: Easing.standard})}px)`,
}}
>
{headline}
</div>
) : null}
<div style={{alignItems: "stretch", display: "flex", justifyContent: "center"}}>
{nodes.map((node, index) => {
const delay = 14 + index * 16;
const appear = interpolate(frame, [delay, delay + 18], [0, 1], {easing: Easing.standard});
const sweep = interpolate(frame, [delay - 8, delay + 12], [0, 100], {easing: Easing.standard});
return (
<div key={node.label} style={{alignItems: "center", display: "flex"}}>
{index > 0 ? (
<div style={{background: brand.colors.border, height: 1, position: "relative", width: 116 * scale}}>
<div
style={{
background: `linear-gradient(90deg, ${brand.colors.border}, ${brand.colors.foreground})`,
height: 1,
width: `${sweep}%`,
}}
/>
<span
style={{
background: brand.colors.foreground,
borderRadius: 999,
height: 7 * scale,
left: `${sweep}%`,
opacity: sweep > 2 && sweep < 99 ? 1 : 0,
position: "absolute",
top: -3 * scale,
width: 7 * scale,
}}
/>
</div>
) : null}
<div
style={{
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 14 * scale,
display: "grid",
gap: 10 * scale,
minWidth: 280 * scale,
opacity: appear,
padding: `${30 * scale}px ${32 * scale}px`,
transform: `translateY(${(1 - appear) * 22 * scale}px)`,
}}
>
<span
style={{
fontFamily: brand.typography.mono,
fontSize: 34 * scale,
fontWeight: 500,
letterSpacing: "-0.02em",
}}
>
{node.label}
</span>
{node.detail ? (
<span style={{color: brand.colors.muted, fontSize: 25 * scale, letterSpacing: "-0.01em"}}>
{node.detail}
</span>
) : null}
</div>
</div>
);
})}
</div>
</Fill>
);
};
videos/components/connection-story/connection-story.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {ConnectionStory} from "./connection-story";
export default defineComponentPreview({
title: "Connection story",
category: "Narrative",
description: "A left-to-right pipeline of connected stages.",
component: ConnectionStory,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {headline: {type: "text", defaultValue: "One definition, every render path."}},
examples: [
{
name: "Pipeline",
props: {
headline: "One definition, every render path.",
nodes: [
{label: "videos/", detail: "source of truth"},
{label: "Studio", detail: "instant preview"},
{label: "Export", detail: "frozen manifest"},
],
},
},
],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {ConnectionStory} from "../components/connection-story/connection-story";
<Video>
<Scene id="connection-story" duration="5s">
<ConnectionStory
nodes={[
{
label: "videos/",
detail: "source of truth"
},
{
label: "Studio",
detail: "instant preview"
},
{
label: "Export",
detail: "frozen manifest"
}
]}
headline="One definition, every render path."
/>
</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
- Recommended duration
- 150 frames · 5s
- Minimum duration
- 75 frames · 2.5s
- Entrance
- 26 frames
- Exit
- 10 frames
- Reduced motion
- connects without sweeping links
- Requires
- sans font
- Content limits
- nodes ≤ 4, label ≤ 18
Props
| Prop | Type | Default | Required |
|---|---|---|---|
nodes | ConnectionNode[] | — | Yes |
headline | string | — | — |