Feature tour
Promise, workflow, benefits: the three-beat explainer for a feature nobody has seen yet.
16:9 · every frame here is the frame odori export writes.
The files
A block is a directory, not a bundle. Pick a file to read the source it ships with.
videos/components/end-card/feature-stack/stage/title-reveal/workflow-steps/feature-tour/
videos/feature-tour/video.tsx
import {Scene, Video, defineVideoMetadata} from "odori";
import {EndCard} from "../components/end-card/end-card";
import {FeatureStack} from "../components/feature-stack/feature-stack";
import {Stage} from "../components/stage/stage";
import {TitleReveal} from "../components/title-reveal/title-reveal";
import {WorkflowSteps} from "../components/workflow-steps/workflow-steps";
import {productLayout} from "../layout";
import {tourInput} from "./schema";
/**
* The three-beat product tour: name the promise, walk the workflow, land the
* benefits. Every beat is one component, so retiming is a duration edit.
*/
export const metadata = defineVideoMetadata({
title: "Feature tour",
description: "Promise, workflow, benefits: the three-beat product tour.",
duration: "16s",
layout: productLayout,
schema: tourInput,
tags: ["explainer", "product"],
thumbnailFrame: 60,
});
export default function FeatureTourVideo({headline, callToAction}: {headline: string; callToAction: string}) {
return (
<Video>
<Scene id="promise" duration="4s" name="Promise">
<Stage>
<TitleReveal title={headline} detail="Three beats, one cut." />
</Stage>
</Scene>
<Scene id="workflow" duration="6s" name="Workflow">
<Stage grid={false}>
<WorkflowSteps headline="How a video gets made" steps={["Author", "Preview", "Test", "Export"]} hold={40} />
</Stage>
</Scene>
<Scene id="benefits" duration="4s" name="Benefits">
<Stage>
<FeatureStack
headline="What you get"
features={[
{title: "Fast", detail: "Preview needs no encode."},
{title: "Typed", detail: "Inputs validated before a render starts."},
]}
hold={60}
/>
</Stage>
</Scene>
<Scene id="end" duration="2s" name="End">
<Stage>
<EndCard title="Author. Preview. Ship." callToAction={callToAction} />
</Stage>
</Scene>
</Video>
);
}
videos/feature-tour/schema.ts
import {defineInputSchema} from "odori";
export const tourInput = defineInputSchema({
headline: {type: "text", defaultValue: "Everything in one file.", maxLength: 48},
callToAction: {type: "text", defaultValue: "pnpm create odori@latest", maxLength: 32},
});
videos/layout.tsx
import {defineBrand, defineVideoLayout} from "odori";
import {uiReveal} from "./components/ui-reveal/ui-reveal";
import {uiKey} from "./components/ui-key/ui-key";
/**
* Brand is policy: tokens, fonts, motion, and audio defaults that every video
* inherits. Font files are served from public/, so preview and render resolve
* the same faces.
*/
export const odoriBrand = defineBrand({
name: "odori",
colors: {
background: "#000000",
surface: "#0a0a0a",
foreground: "#ededed",
muted: "#a1a1a1",
accent: "#ffffff",
border: "#1f1f1f",
},
typography: {
sans: '"Geist Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
mono: '"Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
},
fonts: [
{family: "Geist Sans", url: "/fonts/Geist-Variable.woff2", weight: "100 900"},
{family: "Geist Mono", url: "/fonts/GeistMono-Variable.woff2", weight: "100 900"},
],
motion: {standard: [0.16, 1, 0.3, 1], staggerFrames: 3},
audio: {
// Named so a video asks for a role and the brand decides the track.
cues: {
"bed.main": "/audio/bed-energetic.m4a",
"bed.calm": "/audio/bed-soft-pulse.m4a",
"bed.warm": "/audio/bed-warm-minimal.m4a",
"ui.confirm": "/audio/ui-reveal.m4a",
// A generated cue: source in this repository, rendered identically for
// preview and for the encode.
"ui.key": uiKey(),
"ui.reveal": uiReveal(),
},
targetLufs: -14,
},
});
export const productLayout = defineVideoLayout({
format: {width: 1920, height: 1080, fps: 30},
brand: odoriBrand,
safeArea: {x: 96, y: 72},
audio: {palette: "product-cinematic", targetLufs: -14},
});
videos/components/stage/stage.tsx
import type {CSSProperties, ReactNode} from "react";
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale, useVideo} from "odori";
export type StageProps = {
children?: ReactNode;
/** A hairline grid keeps large empty frames from feeling flat. */
grid?: boolean;
/** A slow radial glow behind the content. */
glow?: boolean;
align?: "center" | "start";
style?: CSSProperties;
};
/**
* The shared backdrop. Every scene sits on the same surface so cuts feel like
* one film rather than a stack of unrelated slides.
*/
/** Relative luminance, so the backdrop adapts to a light or dark brand. */
const isLight = (hex: string) => {
const value = hex.replace("#", "");
const full = value.length === 3 ? value.split("").map((part) => part + part).join("") : value;
const [red, green, blue] = [0, 2, 4].map((offset) => parseInt(full.slice(offset, offset + 2), 16) / 255);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue > 0.5;
};
export const Stage = ({children, grid = true, glow = true, align = "center", style}: StageProps) => {
const frame = useFrame();
const brand = useBrand();
const {width, height} = useVideo();
const scale = useDesignScale();
const drift = interpolate(frame, [0, 240], [0, 1], {extrapolateRight: "extend"});
const cell = 80 * scale;
const light = isLight(brand.colors.background);
const glowColor = light ? "rgba(0,0,0,0.05)" : "rgba(255,255,255,0.07)";
const vignette = light ? "rgba(0,0,0,0.06)" : "rgba(0,0,0,0.55)";
return (
<Fill style={{background: brand.colors.background, ...style}}>
{grid ? (
<Fill
style={{
backgroundImage: `linear-gradient(to right, ${brand.colors.border} 1px, transparent 1px), linear-gradient(to bottom, ${brand.colors.border} 1px, transparent 1px)`,
backgroundSize: `${cell}px ${cell}px`,
maskImage: `radial-gradient(ellipse ${width * 0.55}px ${height * 0.5}px at 50% 45%, rgba(0,0,0,0.65), transparent 78%)`,
WebkitMaskImage: `radial-gradient(ellipse ${width * 0.55}px ${height * 0.5}px at 50% 45%, rgba(0,0,0,0.65), transparent 78%)`,
opacity: interpolate(frame, [0, 24], [0, 0.7], {easing: Easing.standard}),
transform: `translateY(${drift * -14 * scale}px)`,
}}
/>
) : null}
{glow ? (
<Fill
style={{
backgroundImage: `radial-gradient(ellipse ${width * 0.45}px ${height * 0.42}px at 50% ${
38 + drift * 3
}%, ${glowColor}, transparent 70%)`,
}}
/>
) : null}
<Fill
style={{
alignItems: "center",
justifyContent: align === "center" ? "center" : "flex-start",
}}
>
{children}
</Fill>
<Fill
style={{
boxShadow: `inset 0 0 ${240 * scale}px ${vignette}`,
pointerEvents: "none",
}}
/>
</Fill>
);
};
videos/components/title-reveal/title-reveal.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type TitleRevealProps = {
title: string;
detail?: string;
align?: "left" | "center";
};
/**
* A masked, word-staggered headline. Each word rises out of a clipped line
* box, so the reveal reads as typography rather than a fade.
*/
export const TitleReveal = ({title, detail, align = "center"}: TitleRevealProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const lines = title.split("\n");
let wordIndex = 0;
return (
<Fill
style={{
alignItems: align === "center" ? "center" : "flex-start",
gap: 34 * scale,
justifyContent: "center",
padding: `${120 * scale}px ${140 * scale}px`,
textAlign: align,
}}
>
<div style={{display: "grid", gap: 6 * scale, maxWidth: 1560 * scale}}>
{lines.map((line, lineNumber) => (
<div
key={`${line}-${lineNumber}`}
style={{
display: "flex",
flexWrap: "wrap",
gap: `0 ${26 * scale}px`,
justifyContent: align === "center" ? "center" : "flex-start",
overflow: "hidden",
paddingBottom: 12 * scale,
}}
>
{line.split(/\s+/).map((word) => {
const delay = 4 + wordIndex * brand.motion.staggerFrames;
wordIndex += 1;
const progress = interpolate(frame, [delay, delay + 24], [0, 1], {easing: Easing.standard});
return (
<span
key={`${word}-${wordIndex}`}
style={{
display: "inline-block",
fontSize: 116 * scale,
fontWeight: 560,
letterSpacing: "-0.045em",
lineHeight: 1.04,
opacity: progress,
transform: `translateY(${(1 - progress) * 78 * scale}px)`,
}}
>
{word}
</span>
);
})}
</div>
))}
</div>
{detail ? (
<div
style={{
color: brand.colors.muted,
fontSize: 36 * scale,
letterSpacing: "-0.015em",
maxWidth: 1100 * scale,
opacity: interpolate(frame, [16, 34], [0, 1], {easing: Easing.standard}),
transform: `translateY(${interpolate(frame, [16, 34], [14 * scale, 0], {easing: Easing.standard})}px)`,
}}
>
{detail}
</div>
) : null}
</Fill>
);
};
videos/components/workflow-steps/workflow-steps.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale, useVideo} from "odori";
export type WorkflowStepsProps = {
steps: string[];
headline?: string;
/** Frames each step holds the focus. */
hold?: number;
};
/**
* A sequential process with exactly one active step. Completed steps stay on
* screen at reduced weight, so the viewer keeps the whole path in view while
* the focus advances.
*/
export const WorkflowSteps = ({steps, headline, hold = 40}: WorkflowStepsProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const {width, height} = useVideo();
const stacked = height >= width;
const active = Math.min(steps.length - 1, Math.floor(Math.max(0, frame - 14) / hold));
return (
<Fill style={{gap: 46 * scale, justifyContent: "center", padding: `${110 * scale}px ${120 * scale}px`}}>
{headline ? (
<div style={{fontSize: 58 * scale, fontWeight: 560, letterSpacing: "-0.04em", opacity: interpolate(frame, [0, 16], [0, 1])}}>
{headline}
</div>
) : null}
<div style={{alignItems: "stretch", display: "flex", flexDirection: stacked ? "column" : "row", gap: 20 * scale}}>
{steps.map((step, index) => {
const enter = interpolate(frame, [10 + index * 6, 10 + index * 6 + 18], [0, 1], {easing: Easing.standard});
const isActive = index === active;
const done = index < active;
return (
<div
key={step}
style={{
alignItems: "center",
background: isActive ? `color-mix(in srgb, ${brand.colors.accent} 14%, ${brand.colors.surface})` : brand.colors.surface,
border: `1px solid ${isActive ? brand.colors.accent : brand.colors.border}`,
borderRadius: 22 * scale,
display: "flex",
flex: 1,
gap: 18 * scale,
opacity: enter * (done ? 0.6 : 1),
padding: `${26 * scale}px ${28 * scale}px`,
transform: `translateY(${(1 - enter) * 16 * scale}px)`,
}}
>
<span
style={{
alignItems: "center",
background: isActive ? brand.colors.accent : "transparent",
border: `${2 * scale}px solid ${isActive ? brand.colors.accent : brand.colors.border}`,
borderRadius: 999,
color: isActive ? brand.colors.background : brand.colors.muted,
display: "flex",
fontFamily: brand.typography.mono,
fontSize: 24 * scale,
height: 46 * scale,
justifyContent: "center",
width: 46 * scale,
}}
>
{index + 1}
</span>
<span style={{fontSize: 34 * scale, letterSpacing: "-0.02em"}}>{step}</span>
</div>
);
})}
</div>
</Fill>
);
};
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/end-card/end-card.tsx
import {Fill, Easing, interpolate, spring, useBrand, useFrame, useDesignScale, useVideo} from "odori";
export type EndCardProps = {
title: string;
detail?: string;
callToAction?: string;
};
/** The closing lockup: the statement, and one command to run. */
export const EndCard = ({title, detail, callToAction}: EndCardProps) => {
const frame = useFrame();
const brand = useBrand();
const {fps} = useVideo();
const scale = useDesignScale();
const rise = spring({frame, fps, from: 0.96, to: 1, damping: 20});
const line = interpolate(frame, [8, 34], [0, 1], {easing: Easing.standard});
return (
<Fill style={{alignItems: "center", gap: 30 * scale, justifyContent: "center", padding: 110 * scale}}>
<div
style={{
fontSize: 108 * scale,
fontWeight: 560,
letterSpacing: "-0.05em",
lineHeight: 1.02,
maxWidth: 1400 * scale,
opacity: interpolate(frame, [0, 18], [0, 1], {easing: Easing.standard}),
textAlign: "center",
transform: `scale(${rise})`,
whiteSpace: "pre-line",
}}
>
{title}
</div>
<div
style={{
background: `linear-gradient(90deg, transparent, ${brand.colors.border}, transparent)`,
height: 1,
opacity: line,
width: `${line * 46}%`,
}}
/>
{detail ? (
<div
style={{
color: brand.colors.muted,
fontSize: 34 * scale,
letterSpacing: "-0.015em",
opacity: interpolate(frame, [14, 30], [0, 1], {easing: Easing.standard}),
textAlign: "center",
}}
>
{detail}
</div>
) : null}
{callToAction ? (
<div
style={{
alignItems: "center",
background: "rgba(255,255,255,0.04)",
border: `1px solid ${brand.colors.border}`,
borderRadius: 10 * scale,
color: brand.colors.foreground,
display: "flex",
fontFamily: brand.typography.mono,
fontSize: 28 * scale,
gap: 14 * scale,
marginTop: 8 * scale,
opacity: interpolate(frame, [22, 38], [0, 1], {easing: Easing.standard}),
padding: `${16 * scale}px ${28 * scale}px`,
transform: `translateY(${interpolate(frame, [22, 38], [12 * scale, 0], {easing: Easing.standard})}px)`,
}}
>
<span style={{color: brand.colors.muted}}>{"$"}</span>
{callToAction}
</div>
) : null}
</Fill>
);
};
Remix it
Scaffold a project, install the components this block uses, then paste the entry into videos/feature-tour/video.tsx. Every file above is yours to edit from that point on.
pnpm create odori@latest my-videopnpm odori add @odori/stage @odori/title-reveal @odori/workflow-steps @odori/feature-stack @odori/end-card