Shared axis
Related scenes travelling on one spatial axis.
Customize
import {Scene, Video} from "odori";
import {SharedAxis} from "../components/shared-axis/shared-axis";
<Video>
<Scene id="shared-axis" duration="3s">
<SharedAxis
axis="x"
distance={0.12}
/>
</Scene>
</Video>Installation
pnpm odori add component shared-axisnpx odori add component shared-axisbunx odori add component shared-axisEdit the source in videos/components/shared-axis/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/shared-axis/shared-axis.tsx
import type {ReactNode} from "react";
import {Easing, Fill, interpolate, useSceneTransition} from "odori";
export type SharedAxisProps = {
children?: ReactNode;
/** The axis the pair travels on. */
axis?: "x" | "y" | "z";
/** How far the move carries, as a fraction of the frame. */
distance?: number;
};
/**
* Two related scenes travelling on one axis.
*
* The claim a shared axis makes is that these two scenes are *the same kind of
* thing*, one after another — a list and its detail, a before and its after.
* That reads only if both halves move together on one line and fade across
* each other, so the move is short, the fade is quick, and neither scene ever
* translates in a direction the other does not.
*
* The `z` axis is the depth variant: forward for arriving, back for leaving,
* which is the one to use when the second scene is *inside* the first rather
* than beside it.
*
* ```tsx
* <Scene id="detail" duration="4s" overlap="0.4s">
* <SharedAxis axis="x">{content}</SharedAxis>
* </Scene>
* ```
*/
export const SharedAxis = ({children, axis = "x", distance = 0.12}: SharedAxisProps) => {
const {entering, leaving} = useSceneTransition();
const arriving = interpolate(entering, [0, 1], [0, 1], {easing: Easing.standard});
const departing = interpolate(leaving, [0, 1], [0, 1], {easing: Easing.standard});
const travelling = arriving < 1;
// One line, one direction: arriving comes from behind the axis origin,
// leaving continues past it.
const shift = travelling ? (1 - arriving) * distance * 100 : -departing * distance * 100;
const depth = travelling ? 0.94 + arriving * 0.06 : 1 + departing * 0.06;
const transform =
axis === "z"
? `scale(${depth})`
: axis === "x"
? `translateX(${shift}%)`
: `translateY(${shift}%)`;
// The fade is fast at both ends so the two scenes are never both at half
// strength, which is what makes a cross-fade look like a mistake.
const opacity = travelling
? interpolate(arriving, [0, 0.45], [0, 1], {easing: Easing.standard})
: interpolate(departing, [0.35, 1], [1, 0], {easing: Easing.standard});
return <Fill style={{opacity, transform, transformOrigin: "center"}}>{children}</Fill>;
};
videos/components/shared-axis/shared-axis.preview.tsx
import {useBrand} from "odori";
import {defineComponentPreview} from "odori/preview";
import {SharedAxis} from "./shared-axis";
const Panel = () => {
const brand = useBrand();
return (
<div
style={{
alignItems: "center",
background: brand.colors.background,
color: brand.colors.foreground,
display: "flex",
flexDirection: "column",
fontFamily: "ui-sans-serif, system-ui",
gap: 18,
inset: 0,
justifyContent: "center",
position: "absolute",
}}
>
<strong style={{fontSize: 84, letterSpacing: "-0.04em"}}>Deployment detail</strong>
<span style={{color: brand.colors.muted, fontSize: 32}}>the same thing, one level in</span>
</div>
);
};
export default defineComponentPreview({
title: "Shared axis",
category: "Motion",
description: "Related scenes travelling on one spatial axis.",
component: SharedAxis,
canvas: {width: 1920, height: 1080, duration: "3s"},
controls: {
axis: {type: "select", defaultValue: "x", options: ["x", "y", "z"]},
distance: {type: "number", defaultValue: 0.12, min: 0.02, max: 0.4, step: 0.02},
},
examples: [
{name: "Default", props: {children: <Panel />}},
{name: "Depth", props: {axis: "z", children: <Panel />}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {SharedAxis} from "../components/shared-axis/shared-axis";
<Video>
<Scene id="shared-axis" duration="3s">
<SharedAxis
axis="x"
distance={0.12}
/>
</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
- 90 frames · 3s
- Minimum duration
- 30 frames · 1s
- Entrance
- 12 frames
- Exit
- 12 frames
- Reduced motion
- the scenes cross-fade without moving
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
children | ReactNode | — | — |
axisThe axis the pair travels on. | "x" | "y" | "z" | "x" | — |
distanceHow far the move carries, as a fraction of the frame. | number | 0.12 | — |
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.