Whip pan
A fast directional camera move with a clean settle.
Customize
import {Scene, Video} from "odori";
import {WhipPan} from "../components/whip-pan/whip-pan";
<Video>
<Scene id="whip-pan" duration="1.5s">
<WhipPan
direction="left"
at={0}
durationInFrames={18}
blur={26}
/>
</Scene>
</Video>Installation
pnpm odori add component whip-pannpx odori add component whip-panbunx odori add component whip-panEdit the source in videos/components/whip-pan/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/whip-pan/whip-pan.tsx
import type {ReactNode} from "react";
import {Easing, Fill, interpolate, useFrame} from "odori";
export type WhipPanProps = {
children?: ReactNode;
/** Which way the camera travels. */
direction?: "left" | "right" | "up" | "down";
/** Frame the pan starts. */
at?: number;
/** Frames the whole move takes, including the settle. */
durationInFrames?: number;
/** How far the content overshoots, as a fraction of the frame. */
distance?: number;
/** Peak motion blur in pixels. Zero renders the move clean. */
blur?: number;
};
/**
* A fast directional move with a clean settle.
*
* Two things make a whip pan read as a camera rather than a slide: the speed
* is not constant — almost all the distance is covered in the first third —
* and the frame smears while it travels. The blur is the part people leave
* out, and without it a fast translate looks like a dropped frame.
*
* The settle overshoots slightly and comes back, because a camera stopping
* dead is the one thing a real one never does.
*/
export const WhipPan = ({
children,
direction = "left",
at = 0,
durationInFrames = 18,
distance = 1.15,
blur = 26,
}: WhipPanProps) => {
const frame = useFrame();
const local = frame - at;
const end = durationInFrames;
// Distance is covered fast, then unwound past zero and settled back.
const travel = interpolate(local, [0, end * 0.45, end * 0.78, end], [1, 0.12, -0.035, 0], {
easing: Easing.standard,
});
// Blur follows speed, not position: it peaks mid-move and is gone by the
// settle, which is what keeps the last frame sharp.
const smear = interpolate(local, [0, end * 0.3, end * 0.7, end], [blur * 0.4, blur, blur * 0.25, 0], {
easing: Easing.standard,
});
const horizontal = direction === "left" || direction === "right";
const sign = direction === "left" || direction === "up" ? 1 : -1;
const offset = travel * distance * 100 * sign;
return (
<Fill
style={{
filter: smear > 0.5 ? `blur(${horizontal ? smear : 0}px)` : undefined,
transform: horizontal ? `translateX(${offset}%)` : `translateY(${offset}%)`,
}}
>
{children}
</Fill>
);
};
videos/components/whip-pan/whip-pan.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {WhipPan} from "./whip-pan";
const Panel = () => (
<div
style={{
alignItems: "center",
background: "linear-gradient(135deg, #101010, #050505)",
color: "#f5f5f5",
display: "flex",
flexDirection: "column",
fontFamily: "ui-sans-serif, system-ui",
gap: 20,
inset: 0,
justifyContent: "center",
position: "absolute",
}}
>
<strong style={{fontSize: 92, letterSpacing: "-0.04em"}}>Next scene</strong>
<span style={{color: "#8f8f8f", fontSize: 34}}>arrived on the whip</span>
</div>
);
export default defineComponentPreview({
title: "Whip pan",
category: "Motion",
description: "A fast directional camera move with a clean settle.",
component: WhipPan,
canvas: {width: 1920, height: 1080, duration: "2s"},
controls: {
direction: {type: "select", defaultValue: "left", options: ["left", "right", "up", "down"]},
at: {type: "number", defaultValue: 0, min: 0, max: 40},
durationInFrames: {type: "number", defaultValue: 18, min: 8, max: 45},
blur: {type: "number", defaultValue: 26, min: 0, max: 60},
},
examples: [
{name: "Default", props: {children: <Panel />}},
{name: "Upward", props: {direction: "up", children: <Panel />}},
{name: "No blur", props: {blur: 0, children: <Panel />}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {WhipPan} from "../components/whip-pan/whip-pan";
<Video>
<Scene id="whip-pan" duration="1.5s">
<WhipPan
direction="left"
at={0}
durationInFrames={18}
blur={26}
/>
</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
- 45 frames · 1.5s
- Minimum duration
- 18 frames · 0.6s
- Entrance
- 18 frames
- Exit
- 0 frames
- Reduced motion
- the content is settled, with no travel and no blur
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
children | ReactNode | — | — |
directionWhich way the camera travels. | "left" | "right" | "up" | "down" | "left" | — |
atFrame the pan starts. | number | 0 | — |
durationInFramesFrames the whole move takes, including the settle. | number | 18 | — |
distanceHow far the content overshoots, as a fraction of the frame. | number | 1.15 | — |
blurPeak motion blur in pixels. Zero renders the move clean. | number | 26 | — |
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.