Split wipe
Two panels parting to reveal the scene behind them.
Customize
import {Scene, Video} from "odori";
import {SplitWipe} from "../components/split-wipe/split-wipe";
<Video>
<Scene id="split-wipe" duration="2s">
<SplitWipe
direction="horizontal"
at={0}
durationInFrames={26}
seam={true}
/>
</Scene>
</Video>Installation
pnpm odori add component split-wipenpx odori add component split-wipebunx odori add component split-wipeEdit the source in videos/components/split-wipe/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/split-wipe/split-wipe.tsx
import type {ReactNode} from "react";
import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type SplitWipeProps = {
/** What is revealed as the panels part. */
children?: ReactNode;
/** Which way the two panels travel. */
direction?: "horizontal" | "vertical";
/** Frame the wipe begins. */
at?: number;
/** How long the panels take to clear the frame. */
durationInFrames?: number;
/** Colour of the panels. Defaults to the brand background. */
color?: string;
/** A hairline down the seam, so the split reads before it opens. */
seam?: boolean;
};
/**
* Two panels parting to reveal what is behind them.
*
* The panels are the transition, not a fade of the content: content that fades
* in has no edge and reads as a slideshow, while an edge travelling across the
* frame reads as a cut with intent. Both halves move together, so the reveal
* grows from the centre and the eye stays where the next scene begins.
*/
export const SplitWipe = ({
children,
direction = "horizontal",
at = 0,
durationInFrames = 26,
color,
seam = true,
}: SplitWipeProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const progress = interpolate(frame, [at, at + durationInFrames], [0, 1], {easing: Easing.standard});
const plate = color ?? brand.colors.background;
const vertical = direction === "vertical";
const shift = `${progress * 100}%`;
return (
<Fill>
{children}
{/* Two panels, each covering half and leaving in opposite directions.
They stay mounted once open, which costs nothing and keeps the
component's output identical on every frame it renders. */}
{[0, 1].map((index) => (
<div
key={index}
aria-hidden
style={{
background: plate,
height: vertical ? "50%" : "100%",
left: vertical ? 0 : index === 0 ? 0 : "50%",
position: "absolute",
top: vertical ? (index === 0 ? 0 : "50%") : 0,
transform: vertical
? `translateY(${index === 0 ? "-" : ""}${shift})`
: `translateX(${index === 0 ? "-" : ""}${shift})`,
width: vertical ? "100%" : "50%",
}}
/>
))}
{seam && progress < 1 ? (
<div
aria-hidden
style={{
background: brand.colors.border,
height: vertical ? Math.max(1, 2 * scale) : "100%",
left: vertical ? 0 : "50%",
opacity: 1 - progress,
position: "absolute",
top: vertical ? "50%" : 0,
width: vertical ? "100%" : Math.max(1, 2 * scale),
}}
/>
) : null}
</Fill>
);
};
videos/components/split-wipe/split-wipe.preview.tsx
import {useBrand} from "odori";
import {defineComponentPreview} from "odori/preview";
import {SplitWipe} from "./split-wipe";
const Behind = () => {
const brand = useBrand();
return (
<div
style={{
alignItems: "center",
background: brand.colors.background,
color: brand.colors.foreground,
display: "flex",
fontFamily: "ui-sans-serif, system-ui",
fontSize: 96,
inset: 0,
justifyContent: "center",
letterSpacing: "-0.04em",
position: "absolute",
}}
>
Ship the story.
</div>
);
};
export default defineComponentPreview({
title: "Split wipe",
category: "Motion",
description: "Two panels parting to reveal the scene behind them.",
component: SplitWipe,
canvas: {width: 1920, height: 1080, duration: "3s"},
controls: {
direction: {type: "select", defaultValue: "horizontal", options: ["horizontal", "vertical"]},
at: {type: "number", defaultValue: 0, min: 0, max: 60},
durationInFrames: {type: "number", defaultValue: 26, min: 8, max: 90},
seam: {type: "boolean", defaultValue: true},
},
examples: [
{name: "Default", props: {children: <Behind />}},
{name: "Vertical", props: {direction: "vertical", children: <Behind />}},
{name: "Slow", props: {durationInFrames: 60, children: <Behind />}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {SplitWipe} from "../components/split-wipe/split-wipe";
<Video>
<Scene id="split-wipe" duration="2s">
<SplitWipe
direction="horizontal"
at={0}
durationInFrames={26}
seam={true}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Motion
- Recommended duration
- 60 frames · 2s
- Minimum duration
- 26 frames · 0.87s
- Entrance
- 26 frames
- Exit
- 0 frames
- Reduced motion
- the panels are open from the first frame
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
childrenWhat is revealed as the panels part. | ReactNode | — | — |
directionWhich way the two panels travel. | "horizontal" | "vertical" | "horizontal" | — |
atFrame the wipe begins. | number | 0 | — |
durationInFramesHow long the panels take to clear the frame. | number | 26 | — |
colorColour of the panels. Defaults to the brand background. | string | — | — |
seamA hairline down the seam, so the split reads before it opens. | boolean | true | — |
Related components
All componentsCamera stageAn animated camera that moves through your scene.Cell revealTiles slide into place to reveal your content.Charged borderA moving glow around a rounded frame.Dissolve revealA noise threshold sweeps across the frame, so content arrives as a torn edge.Gradient fieldA looping gradient in your brand colors.Halftone revealContent revealed through expanding dots.