Feature stack
A sequence of features in one card.
Customize
import {Scene, Video} from "odori";
import {FeatureStack} from "../components/feature-stack/feature-stack";
<Video>
<Scene id="feature-stack" duration="6s">
<FeatureStack
features={[
{
title: "Fast",
detail: "Preview needs no encode."
},
{
title: "Typed",
detail: "Inputs validated before a render starts."
},
{
title: "Reliable",
detail: "The approved cut is the rendered cut."
}
]}
headline="What you get"
hold={45}
/>
</Scene>
</Video>Installation
pnpm odori add component feature-stacknpx odori add component feature-stackbunx odori add component feature-stackEdit the source in videos/components/feature-stack/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/feature-stack/feature-stack.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type Feature = {title: string; detail?: string};
export type FeatureStackProps = {
features: Feature[];
headline?: string;
/** Frames each feature holds before the next replaces it. */
hold?: number;
};
/**
* Benefits progressing through one stable container. The card never moves or
* resizes; only its contents change, so a viewer can read three claims without
* their eye being dragged around the frame.
*/
export const FeatureStack = ({features, headline, hold = 45}: FeatureStackProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const index = Math.min(features.length - 1, Math.floor(Math.max(0, frame - 12) / hold));
const feature = features[index];
const at = 12 + index * hold;
const enter = interpolate(frame, [at, at + 16], [0, 1], {easing: Easing.standard});
return (
<Fill style={{alignItems: "center", gap: 40 * scale, justifyContent: "center", padding: 110 * scale}}>
{headline ? (
<div style={{color: brand.colors.muted, fontSize: 34 * scale, opacity: interpolate(frame, [0, 16], [0, 1])}}>
{headline}
</div>
) : null}
<div
style={{
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 30 * scale,
display: "grid",
gap: 18 * scale,
minHeight: 320 * scale,
padding: `${52 * scale}px ${56 * scale}px`,
placeContent: "center",
textAlign: "center",
width: 1200 * scale,
}}
>
<div
style={{
fontSize: 76 * scale,
fontWeight: 570,
letterSpacing: "-0.04em",
opacity: enter,
transform: `translateY(${(1 - enter) * 18 * scale}px)`,
}}
>
{feature?.title}
</div>
{feature?.detail ? (
<div style={{color: brand.colors.muted, fontSize: 32 * scale, opacity: enter}}>{feature.detail}</div>
) : null}
</div>
<div style={{display: "flex", gap: 10 * scale}}>
{features.map((item, dot) => (
<span
key={item.title}
style={{
background: dot === index ? brand.colors.accent : brand.colors.border,
borderRadius: 999,
height: 10 * scale,
width: dot === index ? 34 * scale : 10 * scale,
}}
/>
))}
</div>
</Fill>
);
};
videos/components/feature-stack/feature-stack.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {FeatureStack} from "./feature-stack";
export default defineComponentPreview({
title: "Feature stack",
category: "Narrative",
description: "A sequence of features in one card.",
component: FeatureStack,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
headline: {type: "text", defaultValue: "What you get"},
hold: {type: "number", defaultValue: 45, min: 20, max: 90},
},
examples: [
{
name: "Three claims",
props: {
features: [
{title: "Fast", detail: "Preview needs no encode."},
{title: "Typed", detail: "Inputs validated before a render starts."},
{title: "Reliable", detail: "The approved cut is the rendered cut."},
],
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {FeatureStack} from "../components/feature-stack/feature-stack";
<Video>
<Scene id="feature-stack" duration="6s">
<FeatureStack
features={[
{
title: "Fast",
detail: "Preview needs no encode."
},
{
title: "Typed",
detail: "Inputs validated before a render starts."
},
{
title: "Reliable",
detail: "The approved cut is the rendered cut."
}
]}
headline="What you get"
hold={45}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Narrative
- Recommended duration
- 180 frames · 6s
- Minimum duration
- 90 frames · 3s
- Entrance
- 28 frames
- Exit
- 0 frames
- Reduced motion
- features cross-fade without travel
- Requires
- sans font
- Content limits
- features ≤ 4, titleLength ≤ 24, detailLength ≤ 56
Props
| Prop | Type | Default | Required |
|---|---|---|---|
features | Feature[] | — | Yes |
headline | string | — | — |
holdFrames each feature holds before the next replaces it. | number | 45 | — |
Related components
All componentsBefore and afterDirect visual comparison with a controlled divider.ComparisonTwo options contrasted through matched structure.Connection storyA left-to-right pipeline of connected stages.End cardThe closing frame: title, supporting line, and a call to action.State transitionBefore, processing, and resolved product states.TimelineMilestones revealed along a timeline.