split-layout
Responsive two-up composition for proof and explanation.
import {Scene, Video} from "odori";
import {SplitLayout} from "../components/split-layout/split-layout";
<Video>
<Scene id="split-layout" duration="4s">
<SplitLayout
title="Story on one side."
detail="Proof on the other, in the same frame."
side="left"
ratio={0.42}
/>
</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/split-layoutnpx odori add @odori/split-layoutbunx odori add @odori/split-layoutSource is copied into videos/components/split-layout/ 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/split-layout/split-layout.tsx
import type {ReactNode} from "react";
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale, useVideo} from "odori";
export type SplitLayoutProps = {
title: string;
detail?: string;
/** The proof side: code, a product frame, a chart. */
children?: ReactNode;
/** Which side the words sit on in a wide format. */
side?: "left" | "right";
/** Share of the frame the words take. */
ratio?: number;
};
/**
* Explanation beside proof. In a wide format the two sit side by side; in a
* tall one they stack, because a 9:16 column cannot hold two readable panels.
*/
export const SplitLayout = ({title, detail, children, side = "left", ratio = 0.42}: SplitLayoutProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const {width, height} = useVideo();
const stacked = height >= width;
const words = interpolate(frame, [0, 26], [0, 1], {easing: Easing.standard});
const proof = interpolate(frame, [14, 44], [0, 1], {easing: Easing.standard});
const share = `${Math.min(Math.max(ratio, 0.3), 0.6) * 100}%`;
return (
<Fill
style={{
alignItems: "stretch",
display: "flex",
flexDirection: stacked ? "column" : side === "left" ? "row" : "row-reverse",
gap: 56 * scale,
padding: `${100 * scale}px ${110 * scale}px`,
}}
>
<div
style={{
alignContent: "center",
display: "grid",
flex: stacked ? "none" : `0 0 ${share}`,
gap: 20 * scale,
opacity: words,
transform: `translateY(${(1 - words) * 24 * scale}px)`,
}}
>
<div style={{fontSize: 78 * scale, fontWeight: 570, letterSpacing: "-0.045em", lineHeight: 1.06}}>{title}</div>
{detail ? <div style={{color: brand.colors.muted, fontSize: 32 * scale, lineHeight: 1.45}}>{detail}</div> : null}
</div>
<div
style={{
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 26 * scale,
flex: 1,
opacity: proof,
overflow: "hidden",
position: "relative",
transform: `translateY(${(1 - proof) * 28 * scale}px)`,
}}
>
{children}
</div>
</Fill>
);
};
videos/components/split-layout/split-layout.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {SplitLayout} from "./split-layout";
export default defineComponentPreview({
title: "Split layout",
category: "Foundation",
description: "Responsive two-up composition for proof and explanation.",
component: SplitLayout,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
title: {type: "text", defaultValue: "Story on one side.", maxLength: 48},
detail: {type: "text", defaultValue: "Proof on the other, in the same frame."},
side: {type: "select", defaultValue: "left", options: ["left", "right"]},
ratio: {type: "number", defaultValue: 0.42, min: 0.3, max: 0.6, step: 0.02},
},
examples: [
{name: "Words left", props: {}},
{name: "Words right", props: {side: "right"}},
],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {SplitLayout} from "../components/split-layout/split-layout";
<Video>
<Scene id="split-layout" duration="4s">
<SplitLayout
title="Story on one side."
detail="Proof on the other, in the same frame."
side="left"
ratio={0.42}
/>
</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
- Foundation
- Aspect ratios
- 16:9 · 9:16 · 1:1
- Recommended duration
- 120 frames · 4s
- Minimum duration
- 60 frames · 2s
- Entrance
- 44 frames
- Exit
- 0 frames
- Reduced motion
- both panels fade without travel
- Requires
- sans font
- Content limits
- title ≤ 48, detail ≤ 96
Props
| Prop | Type | Default | Required |
|---|---|---|---|
title | string | — | Yes |
detail | string | — | — |
childrenThe proof side: code, a product frame, a chart. | ReactNode | — | — |
sideWhich side the words sit on in a wide format. | "left" | "right" | "left" | — |
ratioShare of the frame the words take. | number | 0.42 | — |