Split layout
Responsive two-up composition for proof and explanation.
Customize
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>Installation
pnpm odori add component split-layoutnpx odori add component split-layoutbunx odori add component split-layoutEdit the source in videos/components/split-layout/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files 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"}},
],
});
Add it to 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>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Foundation
- 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 | — |
Related components
All componentsAbsolute stageFull-frame surface with stable clipping and positioning.Safe areaFormat-aware title and action safety boundaries.StageThe shared backdrop: hairline grid, soft glow, and vignette.AccordionSections opening one at a time, the outgoing one closing as the next grows.Area chartFilled trend reveal for volume and cumulative change.Aurora veilFlowing curtains of colored light.