Comparison
Two options contrasted through matched structure.
Customize
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>Installation
pnpm odori add component comparisonnpx odori add component comparisonbunx odori add component comparisonEdit the source in videos/components/comparison/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files 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"],
},
],
},
},
],
});
Add it to 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>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
- 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 | — | — |
Related components
All componentsBefore and afterDirect visual comparison with a controlled divider.Connection storyA left-to-right pipeline of connected stages.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.