comparison
Two options contrasted through matched structure.
import {Scene, Video} from "odori";
import {Comparison} from "../components/comparison/comparison";
<Video>
<Scene id="comparison" duration="5s">
<Comparison
columns={[
{
title: "Manual edit",
points: [
"Re-cut for every format",
"Copy changes by hand",
"Export to see the change"
]
},
{
title: "Odori",
preferred: true,
points: [
"One layout per format",
"Typed inputs per variant",
"Preview needs no encode"
]
}
]}
headline="Two ways to ship a launch video"
/>
</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/comparisonnpx odori add @odori/comparisonbunx odori add @odori/comparisonSource is copied into videos/components/comparison/ 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/comparison/comparison.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale, useVideo} from "odori";
export type ComparisonColumn = {
title: string;
points: string[];
preferred?: boolean;
};
export type ComparisonProps = {
columns: ComparisonColumn[];
headline?: string;
};
/**
* Two options in matched structure, so the difference is the content rather
* than the layout. The preferred column is marked once, at the top, instead of
* being styled louder throughout.
*/
export const Comparison = ({columns, headline}: ComparisonProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const {width, height} = useVideo();
const stacked = height >= width;
return (
<Fill style={{gap: 40 * scale, justifyContent: "center", padding: `${100 * scale}px ${110 * 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={{display: "flex", flexDirection: stacked ? "column" : "row", gap: 24 * scale}}>
{columns.map((column, columnIndex) => {
const enter = interpolate(frame, [10 + columnIndex * 10, 10 + columnIndex * 10 + 22], [0, 1], {
easing: Easing.standard,
});
return (
<div
key={column.title}
style={{
background: brand.colors.surface,
border: `1px solid ${column.preferred ? brand.colors.accent : brand.colors.border}`,
borderRadius: 26 * scale,
display: "grid",
flex: 1,
gap: 20 * scale,
opacity: enter,
padding: `${34 * scale}px ${34 * scale}px`,
transform: `translateY(${(1 - enter) * 22 * scale}px)`,
}}
>
<div style={{alignItems: "center", display: "flex", gap: 14 * scale}}>
<span style={{fontSize: 44 * scale, fontWeight: 570, letterSpacing: "-0.03em"}}>{column.title}</span>
{column.preferred ? (
<span
style={{
background: brand.colors.accent,
borderRadius: 999,
color: brand.colors.background,
fontFamily: brand.typography.mono,
fontSize: 20 * scale,
padding: `${6 * scale}px ${14 * scale}px`,
}}
>
this one
</span>
) : null}
</div>
{column.points.map((point, pointIndex) => {
const at = 18 + columnIndex * 10 + pointIndex * 7;
const show = interpolate(frame, [at, at + 16], [0, 1], {easing: Easing.standard});
return (
<div
key={point}
style={{
color: brand.colors.muted,
fontSize: 30 * scale,
lineHeight: 1.4,
opacity: show,
transform: `translateX(${(1 - show) * 12 * scale}px)`,
}}
>
{point}
</div>
);
})}
</div>
);
})}
</div>
</Fill>
);
};
videos/components/comparison/comparison.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Comparison} from "./comparison";
export default defineComponentPreview({
title: "Comparison",
category: "Narrative",
description: "Two options contrasted through matched structure.",
component: Comparison,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {headline: {type: "text", defaultValue: "Two ways to ship a launch video"}},
examples: [
{
name: "Manual and Odori",
props: {
columns: [
{title: "Manual edit", points: ["Re-cut for every format", "Copy changes by hand", "Export to see the change"]},
{
title: "Odori",
preferred: true,
points: ["One layout per format", "Typed inputs per variant", "Preview needs no encode"],
},
],
},
},
],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {Comparison} from "../components/comparison/comparison";
<Video>
<Scene id="comparison" duration="5s">
<Comparison
columns={[
{
title: "Manual edit",
points: [
"Re-cut for every format",
"Copy changes by hand",
"Export to see the change"
]
},
{
title: "Odori",
preferred: true,
points: [
"One layout per format",
"Typed inputs per variant",
"Preview needs no encode"
]
}
]}
headline="Two ways to ship a launch video"
/>
</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 · 9:16 · 1:1
- Recommended duration
- 150 frames · 5s
- Minimum duration
- 75 frames · 2.5s
- Entrance
- 60 frames
- Exit
- 0 frames
- Reduced motion
- columns and points appear together
- Requires
- sans font, mono font
- Content limits
- columns ≤ 2, points ≤ 4, pointLength ≤ 42
Props
| Prop | Type | Default | Required |
|---|---|---|---|
columns | ComparisonColumn[] | — | Yes |
headline | string | — | — |