Parallax card
A tilting card with moving light and shadow.
Customize
import {Scene, Video} from "odori";
import {ParallaxCard} from "../components/parallax-card/parallax-card";
<Video>
<Scene id="parallax-card" duration="6s">
<ParallaxCard
tilt={4}
speed={1}
sheen={0.35}
radius={24}
/>
</Scene>
</Video>Installation
pnpm odori add component parallax-cardnpx odori add component parallax-cardbunx odori add component parallax-cardEdit the source in videos/components/parallax-card/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/parallax-card/parallax-card.tsx
import type {ReactNode} from "react";
import {Fill, useBrand, useDesignScale, useFrame} from "odori";
export type ParallaxCardProps = {
/** What rides the plane: a screenshot, a chart, any block of UI. */
children?: ReactNode;
/** Maximum tilt in degrees. Small on purpose; past a few degrees the eye reads a broken window, not a floating object. */
tilt?: number;
/** Multiplier on the drift rate. */
speed?: number;
/** Strength of the travelling sheen, 0 to 1. */
sheen?: number;
/** Corner radius in design pixels. */
radius?: number;
};
/**
* A surface floating on a slowly tilting plane.
*
* The tilt is what tells the eye this is an object in space and not a flat
* paste-in. It is scripted rather than pointed at: each axis follows a sine
* path, and the two periods are incommensurate so the plane wanders without
* visibly repeating — and identically on every render.
*
* Light keeps the story honest. The sheen crosses the face exactly as the
* plane turns through it, and the floor shadow slides the opposite way,
* because that is what a fixed light overhead would do. Cut either and the
* tilt reads as a transform; together they read as a room.
*/
export const ParallaxCard = ({children, tilt = 4, speed = 1, sheen = 0.35, radius = 24}: ParallaxCardProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const t = frame * speed;
const rotateY = Math.sin(t / 46) * tilt;
const rotateX = Math.sin(t / 67 + 2.1) * tilt * 0.7;
// How far the plane leans, -1 to 1, driving the light and the shadow.
const lean = tilt === 0 ? 0 : rotateY / tilt;
const sheenAt = 50 - lean * 42;
// A quiet stand-in so the component shows its move before real content arrives.
const fallback = (
<div
style={{
alignContent: "end",
background: `linear-gradient(150deg, color-mix(in srgb, ${brand.colors.accent} 28%, ${brand.colors.surface}), ${brand.colors.surface})`,
display: "grid",
gap: 16 * scale,
height: 580 * scale,
padding: 44 * scale,
width: 960 * scale,
}}
>
<div style={{background: brand.colors.background, borderRadius: 10 * scale, height: 30 * scale, opacity: 0.9, width: "44%"}} />
<div style={{background: brand.colors.background, borderRadius: 10 * scale, height: 18 * scale, opacity: 0.55, width: "68%"}} />
<div style={{background: brand.colors.background, borderRadius: 10 * scale, height: 18 * scale, opacity: 0.55, width: "58%"}} />
</div>
);
return (
<Fill style={{alignItems: "center", justifyContent: "center"}}>
<div style={{perspective: 1500 * scale, position: "relative"}}>
{/* The floor shadow, moving against the lean the way a grounded shadow does. */}
<div
style={{
background: `color-mix(in srgb, ${brand.colors.foreground} 30%, transparent)`,
borderRadius: "50%",
bottom: -52 * scale,
filter: `blur(${28 * scale}px)`,
height: 54 * scale,
left: "8%",
position: "absolute",
right: "8%",
transform: `translateX(${-lean * 34 * scale}px)`,
}}
/>
<div
style={{
border: `1px solid ${brand.colors.border}`,
borderRadius: radius * scale,
boxShadow: `0 ${20 * scale}px ${56 * scale}px color-mix(in srgb, ${brand.colors.foreground} 14%, transparent)`,
overflow: "hidden",
position: "relative",
transform: `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`,
}}
>
{children ?? fallback}
{/* The sheen, riding the tilt rather than a clock of its own. */}
<div
style={{
background: `linear-gradient(105deg, transparent ${sheenAt - 24}%, rgba(255, 255, 255, ${0.45 * sheen}) ${sheenAt}%, transparent ${sheenAt + 24}%)`,
inset: 0,
position: "absolute",
}}
/>
</div>
</div>
</Fill>
);
};
videos/components/parallax-card/parallax-card.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {ParallaxCard} from "./parallax-card";
const Screenshot = () => (
<div
style={{
background: "linear-gradient(160deg, #17233a, #0b1120)",
display: "grid",
gap: 22,
height: 620,
padding: 48,
width: 1000,
}}
>
<div style={{display: "flex", gap: 10}}>
{["#ff5f57", "#febc2e", "#28c840"].map((dot) => (
<span key={dot} style={{background: dot, borderRadius: "50%", height: 16, width: 16}} />
))}
</div>
<div style={{background: "#f5f7fb", borderRadius: 12, height: 36, opacity: 0.92, width: "42%"}} />
<div style={{background: "#8fa3c8", borderRadius: 10, height: 20, opacity: 0.6, width: "70%"}} />
<div style={{background: "#8fa3c8", borderRadius: 10, height: 20, opacity: 0.6, width: "58%"}} />
<div style={{background: "linear-gradient(120deg, #2f49d0, #7aa2ff)", borderRadius: 16, height: 220, marginTop: 12}} />
</div>
);
export default defineComponentPreview({
title: "Parallax card",
category: "Media/Footage",
description: "A tilting card with moving light and shadow.",
component: ParallaxCard,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
tilt: {type: "number", defaultValue: 4, min: 0, max: 12},
speed: {type: "number", defaultValue: 1, min: 0.25, max: 3},
sheen: {type: "number", defaultValue: 0.35, min: 0, max: 1},
radius: {type: "number", defaultValue: 24, min: 0, max: 60},
},
examples: [
{name: "Screenshot", props: {children: <Screenshot />}},
{name: "More presence", props: {children: <Screenshot />, tilt: 7, sheen: 0.6}},
{name: "Nearly still", props: {tilt: 2, speed: 0.6, sheen: 0.15}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {ParallaxCard} from "../components/parallax-card/parallax-card";
<Video>
<Scene id="parallax-card" duration="6s">
<ParallaxCard
tilt={4}
speed={1}
sheen={0.35}
radius={24}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Media
- Recommended duration
- 180 frames · 6s
- Minimum duration
- 60 frames · 2s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- the plane holds level with a static sheen and shadow
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
childrenWhat rides the plane: a screenshot, a chart, any block of UI. | ReactNode | — | — |
tiltMaximum tilt in degrees. Small on purpose; past a few degrees the eye reads a broken window, not a floating object. | number | 4 | — |
speedMultiplier on the drift rate. | number | 1 | — |
sheenStrength of the travelling sheen, 0 to 1. | number | 0.35 | — |
radiusCorner radius in design pixels. | number | 24 | — |
Related components
All componentsB-roll windowSupporting footage framed beside the main narrative.Canvas magnifierA magnifying lens over your content.Canvas stageFrame-driven canvas drawing with export parity.CarouselA paced media sequence with stable aspect handling.CRT terminalA retro terminal with curved glass and glowing text.Glare sweepA light reflection that sweeps across your content.