Connection story
A left-to-right pipeline of connected stages.
Customize
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>Installation
pnpm odori add component connection-storynpx odori add component connection-storybunx odori add component connection-storyEdit the source in videos/components/connection-story/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files 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"},
],
},
},
],
});
Add it to 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>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Narrative
- 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 | — | — |
Related components
All componentsBefore and afterDirect visual comparison with a controlled divider.ComparisonTwo options contrasted through matched structure.End cardThe closing frame: title, supporting line, and a call to action.Feature stackA sequence of features in one card.State transitionBefore, processing, and resolved product states.TimelineMilestones revealed along a timeline.