Card deck
Stacked cards that cycle from front to back.
Customize
import {Scene, Video} from "odori";
import {CardDeck} from "../components/card-deck/card-deck";
<Video>
<Scene id="card-deck" duration="8s">
<CardDeck
dwellFrames={50}
swapFrames={18}
offset={16}
/>
</Scene>
</Video>Installation
pnpm odori add component card-decknpx odori add component card-deckbunx odori add component card-deckEdit the source in videos/components/card-deck/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/card-deck/card-deck.tsx
import {Children, type ReactNode} from "react";
import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame} from "odori";
export type CardDeckProps = {
/** The cards, front first. Three to five reads best. */
children?: ReactNode;
/** Frames the front card holds before it yields. */
dwellFrames?: number;
/** Frames the swap takes. */
swapFrames?: number;
/** Vertical spread between stacked cards, in design pixels. */
offset?: number;
};
/**
* A small stack of cards taking turns in front.
*
* The stack is the point: seeing the edges of what is coming makes a rotation
* feel like a deck being worked through rather than a slideshow. On the
* schedule, the front card arcs out to the side and settles at the back in one
* motion while everything behind it steps forward — the swap is a single
* gesture, so the eye follows one card instead of parsing five moves.
*
* Depths are fractional during the swap. Each card interpolates from its slot
* toward the next, which is what keeps the promotion continuous across the
* cycle boundary instead of snapping when the count rolls over.
*/
export const CardDeck = ({children, dwellFrames = 50, swapFrames = 18, offset = 16}: CardDeckProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const supplied = Children.toArray(children);
const cards =
supplied.length > 0
? supplied
: ["Cut our build times in half.", "The docs are the product.", "We migrated in an afternoon."].map((quote) => (
<div
key={quote}
style={{
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 26 * scale,
color: brand.colors.foreground,
display: "grid",
fontSize: 40 * scale,
fontWeight: 540,
letterSpacing: "-0.02em",
minHeight: 300 * scale,
padding: 48 * scale,
placeContent: "center",
textAlign: "center",
width: 900 * scale,
}}
>
“{quote}”
</div>
));
const count = cards.length;
const cycle = dwellFrames + swapFrames;
const round = Math.floor(frame / cycle);
const swap = count < 2 ? 0 : interpolate(frame % cycle, [dwellFrames, cycle], [0, 1], {easing: Easing.standard});
return (
<Fill style={{alignItems: "center", justifyContent: "center"}}>
<div style={{display: "grid"}}>
{cards.map((card, index) => {
const slot = (((index - round) % count) + count) % count;
// Where this card sits, front at 0, drifting toward its next slot.
const depth = slot === 0 ? swap * (count - 1) : slot - swap;
const aside = slot === 0 ? Math.sin(swap * Math.PI) * 480 * scale : 0;
const layer = slot === 0 ? (swap < 0.5 ? count + 1 : 0) : count - slot;
return (
<div
key={index}
style={{
filter: `brightness(${1 - depth * 0.05})`,
gridArea: "1 / 1",
opacity: 1 - depth * 0.07,
transform: `translate(${aside}px, ${-depth * offset * scale}px) scale(${1 - depth * 0.06})`,
transformOrigin: "center top",
zIndex: layer,
}}
>
{card}
</div>
);
})}
</div>
</Fill>
);
};
videos/components/card-deck/card-deck.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {CardDeck} from "./card-deck";
const Feature = ({title, body, hue}: {title: string; body: string; hue: string}) => (
<div
style={{
background: `linear-gradient(160deg, ${hue}, #0b1120)`,
borderRadius: 26,
color: "#f5f7fb",
display: "grid",
fontFamily: "ui-sans-serif, system-ui",
gap: 16,
minHeight: 320,
padding: 48,
placeContent: "center",
textAlign: "center",
width: 880,
}}
>
<strong style={{fontSize: 46, letterSpacing: "-0.03em"}}>{title}</strong>
<span style={{fontSize: 28, opacity: 0.7}}>{body}</span>
</div>
);
export default defineComponentPreview({
title: "Card deck",
category: "Interface/Surfaces",
description: "Stacked cards that cycle from front to back.",
component: CardDeck,
canvas: {width: 1920, height: 1080, duration: "8s"},
controls: {
dwellFrames: {type: "number", defaultValue: 50, min: 15, max: 150},
swapFrames: {type: "number", defaultValue: 18, min: 8, max: 45},
offset: {type: "number", defaultValue: 16, min: 6, max: 40},
},
examples: [
{name: "Testimonials", props: {}},
{
name: "Features",
props: {
dwellFrames: 35,
children: [
<Feature key="a" title="Branch previews" body="Every push gets a URL" hue="#233a63" />,
<Feature key="b" title="Instant rollback" body="One click, ten seconds" hue="#2c2354" />,
<Feature key="c" title="Edge caching" body="Close to every visitor" hue="#14324a" />,
<Feature key="d" title="Zero config" body="It reads your framework" hue="#31283e" />,
],
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {CardDeck} from "../components/card-deck/card-deck";
<Video>
<Scene id="card-deck" duration="8s">
<CardDeck
dwellFrames={50}
swapFrames={18}
offset={16}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Interface
- Recommended duration
- 240 frames · 8s
- Minimum duration
- 68 frames · 2.27s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- the deck holds with the first card in front
- Requires
- sans font
- Content limits
- cards ≤ 5
Props
| Prop | Type | Default | Required |
|---|---|---|---|
childrenThe cards, front first. Three to five reads best. | ReactNode | — | — |
dwellFramesFrames the front card holds before it yields. | number | 50 | — |
swapFramesFrames the swap takes. | number | 18 | — |
offsetVertical spread between stacked cards, in design pixels. | number | 16 | — |
Related components
All componentsAccordionSections opening one at a time, the outgoing one closing as the next grows.Browser demoBrowser chrome for deterministic product interface frames.ComboboxA search field that filters a dropdown list.Command menuKeyboard-first action search and selection flow.Context menuThe menu that appears where you clicked, anchored to its own corner.Control stageAn enlarged control with an animated press.