Orbit gallery
Tiles drifting past the front of a shallow 3D ring.
Customize
import {Scene, Video} from "odori";
import {OrbitGallery} from "../components/orbit-gallery/orbit-gallery";
<Video>
<Scene id="orbit-gallery" duration="8s">
<OrbitGallery
radius={430}
speed={0.45}
tilt={8}
/>
</Scene>
</Video>Installation
pnpm odori add component orbit-gallerynpx odori add component orbit-gallerybunx odori add component orbit-galleryEdit the source in videos/components/orbit-gallery/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/orbit-gallery/orbit-gallery.tsx
import {Children, type ReactNode} from "react";
import {Fill, useBrand, useDesignScale, useFrame} from "odori";
export type OrbitGalleryProps = {
/** The tiles on the ring. Four to eight reads best. */
children?: ReactNode;
/** Ring radius in design pixels. */
radius?: number;
/** Rotation in degrees per frame. Slow is the point. */
speed?: number;
/** Camera tilt in degrees, looking slightly down onto the ring. */
tilt?: number;
};
/**
* Tiles on a shallow 3D ring, drifting past the front.
*
* The ring is deliberately shallow — the radius is small next to the
* perspective distance — so depth reads as a gentle lean rather than a
* carousel trick. Every tile is billboarded back toward the camera after
* being placed, because footage viewed edge-on is footage wasted; position
* on the ring carries the depth, and scale and brightness carry the focus.
*
* Nothing is stateful: each tile's angle is the frame times the speed, so
* the front of the ring is a pure function of the clock and the rotation
* survives scrubbing and parallel render chunks untouched.
*/
export const OrbitGallery = ({children, radius = 430, speed = 0.45, tilt = 8}: OrbitGalleryProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const supplied = Children.toArray(children);
const tiles =
supplied.length > 0
? supplied
: Array.from({length: 6}, (_, index) => (
<div
key={index}
style={{
background: `linear-gradient(${125 + index * 40}deg, ${brand.colors.accent} -60%, ${brand.colors.surface} ${52 + (index % 3) * 14}%)`,
border: `1px solid ${brand.colors.border}`,
borderRadius: 18 * scale,
height: 250 * scale,
width: 380 * scale,
}}
/>
));
const count = tiles.length;
return (
<Fill style={{alignItems: "center", justifyContent: "center", perspective: 1700 * scale}}>
<div style={{position: "relative", transform: `rotateX(${-tilt}deg)`, transformStyle: "preserve-3d"}}>
{tiles.map((tile, index) => {
const angle = (index / count) * 360 - frame * speed;
// How close this tile is to facing the viewer, 1 at dead front.
const front = (Math.cos((angle * Math.PI) / 180) + 1) / 2;
return (
<div
key={index}
style={{
filter: `brightness(${0.55 + front * 0.45})`,
left: "50%",
opacity: 0.6 + front * 0.4,
position: "absolute",
top: "50%",
transform: `translate(-50%, -50%) rotateY(${angle}deg) translateZ(${radius * scale}px) rotateY(${-angle}deg) scale(${0.94 + front * 0.12})`,
}}
>
{tile}
</div>
);
})}
</div>
</Fill>
);
};
videos/components/orbit-gallery/orbit-gallery.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {placeholderImage} from "odori";
import {OrbitGallery} from "./orbit-gallery";
export default defineComponentPreview({
title: "Orbit gallery",
category: "Media/Footage",
description: "Tiles drifting past the front of a shallow 3D ring.",
component: OrbitGallery,
canvas: {width: 1920, height: 1080, duration: "8s"},
controls: {
radius: {type: "number", defaultValue: 430, min: 240, max: 700, step: 10},
speed: {type: "number", defaultValue: 0.45, min: 0.1, max: 2, step: 0.05},
tilt: {type: "number", defaultValue: 8, min: -20, max: 20},
},
examples: [
{name: "Default", props: {}},
{
name: "Footage",
props: {
children: Array.from({length: 5}, (_, index) => (
<img
key={index}
src={placeholderImage({label: `shot ${index + 1}`, seed: `orbit-${index}`})}
alt=""
style={{borderRadius: 18, display: "block", height: 250, objectFit: "cover", width: 380}}
/>
)),
speed: 0.6,
},
},
{name: "Tight ring", props: {radius: 300, speed: 0.9, tilt: 14}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {OrbitGallery} from "../components/orbit-gallery/orbit-gallery";
<Video>
<Scene id="orbit-gallery" duration="8s">
<OrbitGallery
radius={430}
speed={0.45}
tilt={8}
/>
</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
- 240 frames · 8s
- Minimum duration
- 90 frames · 3s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- the ring holds still with one tile at the front
- Requires
- Odori
- Content limits
- children ≤ 8
Props
| Prop | Type | Default | Required |
|---|---|---|---|
childrenThe tiles on the ring. Four to eight reads best. | ReactNode | — | — |
radiusRing radius in design pixels. | number | 430 | — |
speedRotation in degrees per frame. Slow is the point. | number | 0.45 | — |
tiltCamera tilt in degrees, looking slightly down onto the ring. | number | 8 | — |
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.