v0
A v0 composer with an animated prompt.
Customize
import {Scene, Video} from "odori";
import {V0} from "../components/v0/v0";
<Video>
<Scene id="v0" duration="7s">
<V0
prompt="A pricing page with three tiers and a yearly toggle."
heading="What can I help you ship?"
model="v0-1.5-lg"
charactersPerSecond={26}
/>
</Scene>
</Video>Installation
pnpm odori add component v0npx odori add component v0bunx odori add component v0Edit the source in videos/components/v0/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/v0/v0.tsx
import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useTyping} from "odori";
export type V0Props = {
/** The prompt typed into the composer. */
prompt: string;
/** The line above the composer. */
heading?: string;
/** What sits in the field before the first character. */
placeholder?: string;
/** Starters under the composer. They clear once typing starts. */
suggestions?: string[];
/** The model chip inside the composer. */
model?: string;
charactersPerSecond?: number;
};
const PAGE = "#000000";
const FIELD = "#121212";
const EDGE = "#2E2E2E";
const INK = "#EDEDED";
const FAINT = "#8F8F8F";
/** v0 sets its interface in Geist. */
const FONT = '"GeistSans", "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif';
/**
* A generative build tool taking a brief.
*
* The composer is a dark textarea rather than a single line, because what gets
* typed here is a paragraph describing something to build, not a question. The
* heading above it holds the frame while the text lands, and the send control
* fills only once there is something to send, so the shot has one change in it
* rather than three.
*/
export const V0 = ({
prompt,
heading = "What can I help you ship?",
placeholder = "Describe what you want to build…",
suggestions = ["Clone a screen", "Landing page", "Dashboard", "Sign up form"],
model = "v0-1.5-lg",
charactersPerSecond = 26,
}: V0Props) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const px = (value: number) => value * scale;
const typed = useTyping(prompt, {from: 20, charactersPerSecond, chunk: 2});
const enter = interpolate(frame, [0, 16], [0, 1], {easing: Easing.standard});
const cleared = interpolate(typed.length > 0 ? frame : 0, [20, 30], [0, 1], {easing: Easing.standard});
const armed = interpolate(typed.length > 0 ? frame : 0, [26, 34], [0, 1], {easing: Easing.standard});
return (
<Fill style={{alignItems: "center", background: PAGE, justifyContent: "center", padding: px(120)}}>
<div
style={{
maxWidth: px(1120),
opacity: enter,
transform: `translateY(${(1 - enter) * px(16)}px)`,
width: "100%",
}}
>
<div
style={{
color: INK,
fontFamily: FONT,
fontSize: px(48),
fontWeight: 600,
letterSpacing: "-0.03em",
marginBottom: px(34),
textAlign: "center",
}}
>
{heading}
</div>
<div
style={{
background: FIELD,
border: `${px(1)}px solid ${EDGE}`,
borderRadius: px(12),
padding: `${px(26)}px ${px(24)}px ${px(18)}px`,
}}
>
<div
style={{
color: typed.length > 0 ? INK : FAINT,
fontFamily: FONT,
fontSize: px(28),
lineHeight: 1.5,
minHeight: px(120),
whiteSpace: "pre-wrap",
}}
>
{typed.length > 0 ? typed.text : placeholder}
{typed.caret ? (
<span
style={{
background: INK,
display: "inline-block",
height: px(28),
marginLeft: px(3),
transform: `translateY(${px(4)}px)`,
width: px(2),
}}
/>
) : null}
</div>
<div style={{alignItems: "center", display: "flex", justifyContent: "space-between", marginTop: px(14)}}>
<span
style={{
border: `${px(1)}px solid ${EDGE}`,
borderRadius: px(8),
color: FAINT,
fontFamily: brand.typography.mono,
fontSize: px(19),
padding: `${px(7)}px ${px(14)}px`,
}}
>
{model}
</span>
<span
style={{
alignItems: "center",
background: `color-mix(in srgb, #FFFFFF ${armed * 100}%, ${EDGE})`,
borderRadius: px(10),
display: "inline-flex",
height: px(52),
justifyContent: "center",
width: px(52),
}}
>
<svg viewBox="0 0 24 24" width={px(24)} height={px(24)}>
<path
d="M12 19V5M12 5l-6 6M12 5l6 6"
fill="none"
stroke={armed > 0.5 ? "#000000" : FAINT}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.4}
/>
</svg>
</span>
</div>
</div>
{suggestions.length > 0 ? (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: px(12),
justifyContent: "center",
marginTop: px(26),
opacity: 1 - cleared,
transform: `translateY(${cleared * px(-10)}px)`,
}}
>
{suggestions.map((item) => (
<span
key={item}
style={{
border: `${px(1)}px solid ${EDGE}`,
borderRadius: px(999),
color: FAINT,
fontFamily: FONT,
fontSize: px(21),
padding: `${px(11)}px ${px(20)}px`,
}}
>
{item}
</span>
))}
</div>
) : null}
</div>
</Fill>
);
};
videos/components/v0/v0.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {V0} from "./v0";
export default defineComponentPreview({
title: "v0",
category: "Agents",
description: "A v0 composer with an animated prompt.",
component: V0,
canvas: {width: 1920, height: 1080, duration: "7s"},
controls: {
prompt: {type: "text", defaultValue: "A pricing page with three tiers and a yearly toggle.", maxLength: 140},
heading: {type: "text", defaultValue: "What can I help you ship?", maxLength: 48},
model: {type: "text", defaultValue: "v0-1.5-lg", maxLength: 20},
charactersPerSecond: {type: "number", defaultValue: 26, min: 8, max: 60, step: 1},
},
examples: [
{name: "Default", props: {}},
{name: "No starters", props: {suggestions: []}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {V0} from "../components/v0/v0";
<Video>
<Scene id="v0" duration="7s">
<V0
prompt="A pricing page with three tiers and a yearly toggle."
heading="What can I help you ship?"
model="v0-1.5-lg"
charactersPerSecond={26}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Agents
- Recommended duration
- 210 frames · 7s
- Minimum duration
- 90 frames · 3s
- Entrance
- 16 frames
- Exit
- 12 frames
- Reduced motion
- starters hidden rather than cleared
- Requires
- sans font, mono font
- Content limits
- prompt ≤ 140, heading ≤ 48
Props
| Prop | Type | Default | Required |
|---|---|---|---|
promptThe prompt typed into the composer. | string | — | Yes |
headingThe line above the composer. | string | "What can I help you ship?" | — |
placeholderWhat sits in the field before the first character. | string | "Describe what you want to build…" | — |
suggestionsStarters under the composer. They clear once typing starts. | string[] | ["Clone a screen" | — |
modelThe model chip inside the composer. | string | "v0-1.5-lg" | — |
charactersPerSecond | number | 26 | — |
Related components
All componentsChatGPTA ChatGPT composer with an animated prompt.ClaudeA Claude composer with an animated prompt.Claude CodeA Claude Code session from prompt to response.fxA coding agent session in a terminal.OpenCodeAn open coding agent's terminal session, with the query typed under its wordmark.Absolute stageFull-frame surface with stable clipping and positioning.