feature-stack
Benefits progress through one stable visual container.
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>The same runtime that renders the export. Scrub to any frame, switch format, and edit the fixture props.
Installation
pnpm odori add @odori/feature-stacknpx odori add @odori/feature-stackbunx odori add @odori/feature-stackSource is copied into videos/components/feature-stack/ and belongs to your repository. odori diff compares it with upstream later; odori update applies the change you accept. It has no registry dependencies.
Copy and paste the following code 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: "Benefits progress through one stable visual container.",
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."},
],
},
},
],
});
Use it in 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>Nothing else to install.
The component imports only from odori, so there are no dependencies to add and no import paths to rewrite.
Timing contract
Every registry component declares how it behaves in time.odori test fails a cut that violates these.
- Family
- Narrative
- Aspect ratios
- 16:9 · 9:16 · 1:1
- 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 | — |