Carousel
A paced media sequence with stable aspect handling.
Customize
import {Scene, Video} from "odori";
import {Carousel} from "../components/carousel/carousel";
<Video>
<Scene id="carousel" duration="8s">
<Carousel
holdInFrames={45}
changeInFrames={14}
indicator={true}
/>
</Scene>
</Video>Installation
pnpm odori add component carouselnpx odori add component carouselbunx odori add component carouselEdit the source in videos/components/carousel/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/carousel/carousel.tsx
import type {ReactNode} from "react";
import {Easing, Fill, interpolate, placeholderImage, useBrand, useDesignScale, useFrame, useVideo} from "odori";
export type CarouselProps = {
/** Items to page through. Strings are treated as image URLs. */
items?: Array<string | ReactNode>;
/** Frames each item holds before the next arrives. */
holdInFrames?: number;
/** Frames the change takes. */
changeInFrames?: number;
/** Dots showing position. */
indicator?: boolean;
};
/**
* A paced sequence with a stable frame around it.
*
* Two things make a carousel read as considered rather than as a slideshow:
* the frame never changes size, so nothing reflows between items, and the
* outgoing item leaves while the incoming one arrives rather than after it.
* Both are handled here so a caller only supplies the items and the pace.
*/
export const Carousel = ({items, holdInFrames = 45, changeInFrames = 14, indicator = true}: CarouselProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const {width, height} = useVideo();
const list =
items && items.length > 0
? items
: [1, 2, 3].map((index) => placeholderImage({label: `slide ${index}`, seed: `slide-${index}`}));
const cycle = holdInFrames + changeInFrames;
const index = Math.floor(frame / cycle) % list.length;
const next = (index + 1) % list.length;
const local = frame % cycle;
const changing = interpolate(local, [holdInFrames, cycle], [0, 1], {easing: Easing.standard});
const render = (item: string | ReactNode, key: number, offset: number, opacity: number) => (
<div
key={key}
style={{
alignItems: "center",
display: "flex",
height: "100%",
inset: 0,
justifyContent: "center",
opacity,
position: "absolute",
transform: `translateX(${offset}%)`,
width: "100%",
}}
>
{typeof item === "string" ? (
<img src={item} alt="" style={{height: "100%", objectFit: "cover", width: "100%"}} />
) : (
item
)}
</div>
);
return (
<Fill style={{alignItems: "center", justifyContent: "center", overflow: "hidden"}}>
<div
style={{
borderRadius: 20 * scale,
height: Math.min(height * 0.72, width * 0.5),
overflow: "hidden",
position: "relative",
width: width * 0.78,
}}
>
{render(list[index], index, changing * -12, 1 - changing)}
{changing > 0 ? render(list[next], next + list.length, (1 - changing) * 12, changing) : null}
</div>
{indicator ? (
<div style={{bottom: 60 * scale, display: "flex", gap: 10 * scale, position: "absolute"}}>
{list.map((_, dot) => (
<span
key={dot}
style={{
background: dot === index ? brand.colors.foreground : brand.colors.border,
borderRadius: 999,
height: 10 * scale,
width: dot === index ? 28 * scale : 10 * scale,
}}
/>
))}
</div>
) : null}
</Fill>
);
};
videos/components/carousel/carousel.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Carousel} from "./carousel";
export default defineComponentPreview({
title: "Carousel",
category: "Media/Footage",
description: "A paced media sequence with stable aspect handling.",
component: Carousel,
canvas: {width: 1920, height: 1080, duration: "8s"},
controls: {
holdInFrames: {type: "number", defaultValue: 45, min: 10, max: 150},
changeInFrames: {type: "number", defaultValue: 14, min: 4, max: 60},
indicator: {type: "boolean", defaultValue: true},
},
examples: [
{name: "Default", props: {}},
{name: "Fast", props: {holdInFrames: 20, changeInFrames: 8}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {Carousel} from "../components/carousel/carousel";
<Video>
<Scene id="carousel" duration="8s">
<Carousel
holdInFrames={45}
changeInFrames={14}
indicator={true}
/>
</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
- 60 frames · 2s
- Entrance
- 14 frames
- Exit
- 14 frames
- Reduced motion
- the first item is shown without paging
- Requires
- Odori
- Content limits
- items ≤ 8
Props
| Prop | Type | Default | Required |
|---|---|---|---|
itemsItems to page through. Strings are treated as image URLs. | Array<string | ReactNode> | — | — |
holdInFramesFrames each item holds before the next arrives. | number | 45 | — |
changeInFramesFrames the change takes. | number | 14 | — |
indicatorDots showing position. | boolean | true | — |
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.CRT terminalA retro terminal with curved glass and glowing text.Glare sweepA light reflection that sweeps across your content.Halftone printText revealed as a pattern of printed dots.