Social announcement
The vertical cut: hook, one metric, and a close, sized for a 9:16 feed.
9:16 · 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/metric-callout/stage/title-reveal/social/announcement/
videos/social/announcement/video.tsx
import {Audio, Scene, Video, defineVideoMetadata} from "odori";
import {EndCard} from "../../components/end-card/end-card";
import {MetricCallout} from "../../components/metric-callout/metric-callout";
import {Stage} from "../../components/stage/stage";
import {TitleReveal} from "../../components/title-reveal/title-reveal";
import {socialLayout} from "../layout";
export const metadata = defineVideoMetadata({
title: "Social announcement",
description: "The vertical cut of the launch story.",
duration: "9s",
layout: socialLayout,
tags: ["social", "vertical"],
thumbnailFrame: 30,
});
export default function SocialAnnouncement() {
return (
<Video>
<Audio src="bed.main" gain={0.65} fadeIn="0.5s" fadeOut="1s" trimStart="6s" duckUnder />
<Scene id="hook" duration="3s" name="Hook">
<Stage>
<TitleReveal align="center" title={"Videos, built\nlike applications."} />
</Stage>
</Scene>
<Scene id="proof" duration="3s" name="Proof">
<Stage grid={false}>
<MetricCallout value={100} suffix="%" label="frame deterministic" detail="Preview equals export, frame for frame." />
</Stage>
</Scene>
<Scene id="end" duration="3s" name="End">
<Stage>
<EndCard title="npm i odori" detail="Author. Preview. Ship." />
</Stage>
</Scene>
</Video>
);
}
videos/social/layout.tsx
import {defineVideoLayout} from "odori";
import {productLayout} from "../layout";
/** Vertical social format inherits the brand and replaces the frame. */
export const socialLayout = defineVideoLayout({
extends: productLayout,
format: {width: 1080, height: 1920},
safeArea: {x: 64, y: 120},
});
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/metric-callout/metric-callout.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type MetricCalloutProps = {
value: number;
label: string;
suffix?: string;
prefix?: string;
decimals?: number;
detail?: string;
};
/** A counted metric. The count is a pure function of the frame. */
export const MetricCallout = ({value, label, suffix = "", prefix = "", decimals = 0, detail}: MetricCalloutProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const counted = interpolate(frame, [0, 44], [0, value], {easing: Easing.out(Easing.cubic)});
const rise = interpolate(frame, [0, 22], [0, 1], {easing: Easing.standard});
return (
<Fill style={{alignItems: "center", gap: 20 * scale, justifyContent: "center", padding: 120 * scale}}>
<div
style={{
backgroundImage: `linear-gradient(180deg, ${brand.colors.foreground} 30%, ${brand.colors.muted} 100%)`,
backgroundClip: "text",
color: "transparent",
fontFamily: brand.typography.sans,
fontSize: 216 * scale,
fontWeight: 560,
letterSpacing: "-0.06em",
lineHeight: 1,
opacity: rise,
transform: `translateY(${(1 - rise) * 40 * scale}px)`,
WebkitBackgroundClip: "text",
}}
>
{prefix}
{counted.toFixed(decimals)}
{suffix}
</div>
<div
style={{
color: brand.colors.foreground,
fontSize: 42 * scale,
letterSpacing: "-0.02em",
opacity: interpolate(frame, [14, 30], [0, 1], {easing: Easing.standard}),
textAlign: "center",
}}
>
{label}
</div>
{detail ? (
<div
style={{
color: brand.colors.muted,
fontSize: 30 * scale,
opacity: interpolate(frame, [22, 38], [0, 1], {easing: Easing.standard}),
textAlign: "center",
}}
>
{detail}
</div>
) : null}
</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/social-announcement/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/metric-callout @odori/end-card