Statement
Animated text with inline product controls.
Customize
import {Scene, Video} from "odori";
import {Statement} from "../components/statement/statement";
<Video>
<Scene id="statement" duration="4s">
<Statement
parts={[
"Create a repo and start a",
{
label: "New",
icon: "+"
},
"project"
]}
reveal="words"
motion="rise"
align="left"
theme="light"
/>
</Scene>
</Video>Installation
pnpm odori add component statementnpx odori add component statementbunx odori add component statementEdit the source in videos/components/statement/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/statement/statement.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type StatementChip = {
/** The chip's text. */
label: string;
/** A glyph drawn before the label: a logo, a symbol, a single letter. */
icon?: string;
/** Which surface the chip is drawn on. */
tone?: "neutral" | "dark" | "accent";
};
export type StatementProps = {
/** The sentence, in order. A string is a word; an object is a chip. */
parts: Array<string | StatementChip>;
/**
* What arrives at a time. Characters read as someone typing, words as
* someone talking, and `none` puts the whole line up at once.
*/
reveal?: "characters" | "words" | "none";
/**
* How a part arrives. `rise` lifts and fades it in; `cut` switches it on.
* A cut is not a lesser rise: a video built entirely of hard cuts has a
* rhythm that any easing softens away.
*/
motion?: "rise" | "cut";
/** Frames between one unit and the next. */
stagger?: number;
/** Where the sentence sits. */
align?: "left" | "center";
/** Light page or dark page. Chips and text follow it. */
theme?: "light" | "dark";
/** A chip that replaces the last chip in place. */
swap?: StatementChip;
/** The frame the swap happens on. */
swapAt?: number;
};
const isChip = (part: string | StatementChip): part is StatementChip => typeof part !== "string";
/**
* A sentence, arriving under its own discipline, with product controls set
* inline where the prose needs one.
*
* Real product videos disagree about how type should arrive, and they are all
* right. One types character by character because it is imitating a person at
* a keyboard. One cuts word by word with no easing anywhere, because every
* other transition in it is a cut and a single eased rise would sound a wrong
* note. One lifts whole phrases because it is narrating. So the discipline is
* a choice here rather than a house opinion baked into three near-identical
* components.
*
* Characters reveal inside a layout that is already the whole sentence: each
* glyph switches on where it will finally sit, so nothing reflows and no caret
* is needed to explain a ragged edge, because there is no ragged edge.
*
* A chip is drawn the way the interface draws it, so a sentence can name a
* control and the next scene can cut to that control at scale without the two
* disagreeing about what the product looks like. `swap` exchanges the last
* chip in place, so one vendor becoming another is a substitution rather than
* a reflow of the line.
*/
export const Statement = ({
parts,
reveal = "words",
motion = "rise",
stagger,
align = "left",
theme = "light",
swap,
swapAt,
}: StatementProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const dark = theme === "dark";
const ink = dark ? brand.colors.background : brand.colors.foreground;
const paper = dark ? brand.colors.foreground : brand.colors.background;
// One frame per character reads as typing; a couple of stagger units per
// word reads as speech.
const step = stagger ?? (reveal === "characters" ? 1 : brand.motion.staggerFrames * 2);
const start = 6;
const lastChip = parts.map(isChip).lastIndexOf(true);
const swapFrame = swapAt ?? 0;
// Where each part begins, counted in whatever unit is being revealed.
const offsets: number[] = [];
let units = 0;
for (const part of parts) {
offsets.push(units);
units += reveal === "characters" && !isChip(part) ? part.length + 1 : 1;
}
const shown = (unit: number) => {
if (reveal === "none") return frame >= start ? 1 : 0;
const at = start + unit * step;
if (motion === "cut") return frame >= at ? 1 : 0;
return interpolate(frame, [at, at + 20], [0, 1], {easing: Easing.standard});
};
const enter = (progress: number) =>
motion === "cut"
? {display: "inline-block", opacity: progress}
: {
display: "inline-block",
opacity: progress,
transform: `translateY(${(1 - progress) * 26 * scale}px)`,
};
const chipSurface = (tone: StatementChip["tone"]) => {
if (tone === "accent") return {background: brand.colors.accent, color: paper, border: "transparent"};
if (tone === "dark") return {background: ink, color: paper, border: "transparent"};
// Mixed from the ink rather than named, so a neutral chip is legible on a
// light brand, a dark brand, and the inverted scenes without being told
// which it is standing on.
return {
background: `color-mix(in srgb, ${ink} 8%, transparent)`,
color: ink,
border: `color-mix(in srgb, ${ink} 20%, transparent)`,
};
};
const chipBody = (chip: StatementChip) => {
const surface = chipSurface(chip.tone);
return (
<span
style={{
alignItems: "center",
background: surface.background,
border: `${Math.max(1, scale)}px solid ${surface.border}`,
borderRadius: 14 * scale,
color: surface.color,
display: "inline-flex",
gap: 10 * scale,
padding: `${6 * scale}px ${16 * scale}px`,
whiteSpace: "nowrap",
}}
>
{chip.icon ? <span style={{fontSize: 46 * scale, lineHeight: 1}}>{chip.icon}</span> : null}
{chip.label}
</span>
);
};
return (
<Fill
style={{
alignItems: align === "center" ? "center" : "flex-start",
// paper, not ink. ink is the type colour, and painting the ground
// with it put light text on a light ground: the dark theme rendered
// the sentence invisible, including in this component's own fixture.
background: dark ? paper : "transparent",
justifyContent: "center",
padding: `${120 * scale}px ${150 * scale}px`,
}}
>
<div
style={{
alignItems: "center",
color: ink,
columnGap: 16 * scale,
display: "flex",
flexWrap: "wrap",
fontSize: 64 * scale,
fontWeight: 500,
justifyContent: align === "center" ? "center" : "flex-start",
letterSpacing: "-0.03em",
lineHeight: 1.35,
rowGap: 8 * scale,
textAlign: align,
}}
>
{parts.map((part, index) => {
const swapping = swap !== undefined && index === lastChip;
const swapped = swapping
? interpolate(frame, [swapFrame, swapFrame + (motion === "cut" ? 1 : 16)], [0, 1], {
easing: Easing.standard,
})
: 0;
if (!isChip(part)) {
// In a pre-measured layout every glyph already occupies its final
// place, so revealing one cannot move the ones after it.
if (reveal === "characters") {
return (
<span key={`${part}-${index}`} style={{display: "inline-block", whiteSpace: "pre"}}>
{[...part].map((character, position) => (
<span key={position} style={{display: "inline-block", opacity: shown(offsets[index] + position)}}>
{character}
</span>
))}
</span>
);
}
return (
<span key={`${part}-${index}`} style={enter(shown(offsets[index]))}>
{part}
</span>
);
}
const entrance = shown(offsets[index]);
return (
<span
key={`chip-${index}`}
style={{
...enter(entrance),
// A chip settles from slightly small, the way a control that
// just appeared under the cursor does. A cut skips the settle.
...(motion === "cut"
? {}
: {transform: `translateY(${(1 - entrance) * 26 * scale}px) scale(${0.92 + entrance * 0.08})`}),
}}
>
{swapping ? (
<span style={{display: "inline-grid"}}>
<span
style={{
gridArea: "1 / 1",
opacity: 1 - swapped,
transform: motion === "cut" ? undefined : `translateY(${swapped * -18 * scale}px)`,
}}
>
{chipBody(part)}
</span>
<span
style={{
gridArea: "1 / 1",
opacity: swapped,
transform: motion === "cut" ? undefined : `translateY(${(1 - swapped) * 18 * scale}px)`,
}}
>
{chipBody(swap)}
</span>
</span>
) : (
chipBody(part)
)}
</span>
);
})}
</div>
</Fill>
);
};
videos/components/statement/statement.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Statement} from "./statement";
export default defineComponentPreview({
title: "Statement",
category: "Typography/Titles",
description: "Animated text with inline product controls.",
component: Statement,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
reveal: {type: "select", options: ["characters", "words", "none"], defaultValue: "words"},
motion: {type: "select", options: ["rise", "cut"], defaultValue: "rise"},
align: {type: "select", options: ["left", "center"], defaultValue: "left"},
theme: {type: "select", options: ["light", "dark"], defaultValue: "light"},
},
examples: [
{
name: "Naming a control",
props: {parts: ["Create a repo and start a", {label: "New", icon: "+"}, "project"]},
},
{
name: "Typed, no caret",
props: {parts: ["Ask anything. Get an answer."], reveal: "characters", align: "center"},
},
{
name: "Hard cut, no easing",
props: {parts: ["One", "command.", "Any", "provider."], motion: "cut", stagger: 5, align: "center"},
},
{
name: "Swapping vendors",
props: {
parts: ["Run CI from", {label: "Buildkite", icon: "▲"}],
swap: {label: "Depot", icon: "▦"},
swapAt: 40,
theme: "dark",
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {Statement} from "../components/statement/statement";
<Video>
<Scene id="statement" duration="4s">
<Statement
parts={[
"Create a repo and start a",
{
label: "New",
icon: "+"
},
"project"
]}
reveal="words"
motion="rise"
align="left"
theme="light"
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Typography
- Recommended duration
- 120 frames · 4s
- Minimum duration
- 54 frames · 1.8s
- Entrance
- 30 frames
- Exit
- 10 frames
- Reduced motion
- every part appears together, and a swap cuts
- Requires
- sans font
- Content limits
- parts ≤ 9, partLength ≤ 44, chipLabel ≤ 14
Props
| Prop | Type | Default | Required |
|---|---|---|---|
partsThe sentence, in order. A string is a word; an object is a chip. | Array<string | StatementChip> | — | Yes |
revealWhat arrives at a time. Characters read as someone typing, words as someone talking, and `none` puts the whole line up at once. | "characters" | "words" | "none" | "words" | — |
motionHow a part arrives. `rise` lifts and fades it in; `cut` switches it on. A cut is not a lesser rise: a video built entirely of hard cuts has a rhythm that any easing softens away. | "rise" | "cut" | "rise" | — |
staggerFrames between one unit and the next. | number | — | — |
alignWhere the sentence sits. | "left" | "center" | "left" | — |
themeLight page or dark page. Chips and text follow it. | "light" | "dark" | "light" | — |
swapA chip that replaces the last chip in place. | StatementChip | — | — |
swapAtThe frame the swap happens on. | number | — | — |
Related components
All componentsCaptionsFrame-accurate caption cues for sound-off playback.Character riseCharacters rise with a crisp per-letter stagger.Draw titleAn outline draws across the line, then fills solid.Flap boardA split-flap board clatters through to the target string.Focus inWords settle from a soft blur into a crisp line.Glitch textA title that arrives through brief seeded interference bursts.