ChatGPT
A ChatGPT composer with an animated prompt.
Customize
import {Scene, Video} from "odori";
import {Chatgpt} from "../components/chatgpt/chatgpt";
<Video>
<Scene id="chatgpt" duration="6s">
<Chatgpt
prompt="Turn last quarter's numbers into a one page brief."
placeholder="Ask anything"
theme="dark"
charactersPerSecond={26}
/>
</Scene>
</Video>Installation
pnpm odori add component chatgptnpx odori add component chatgptbunx odori add component chatgptEdit the source in videos/components/chatgpt/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/chatgpt/chatgpt.tsx
import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useTyping} from "odori";
import type {ReactNode} from "react";
export type ChatGptProps = {
/** The prompt that gets typed. */
prompt: string;
/** What sits in the field before the first character. */
placeholder?: string;
/**
* Starters under the field. They clear as soon as typing starts, the way a
* suggestion stops being a suggestion once you have your own idea.
*/
suggestions?: string[];
/** A mark you have the right to use, drawn above the composer. */
logo?: ReactNode;
theme?: "dark" | "light";
charactersPerSecond?: number;
};
const PALETTE = {
dark: {page: "#000000", field: "#212121", edge: "#303030", ink: "#FFFFFF", faint: "#9B9B9B", accent: "#FFFFFF"},
light: {page: "#FCFCFC", field: "#FFFFFF", edge: "#E5E5E5", ink: "#0D0D0D", faint: "#8F8F8F", accent: "#0D0D0D"},
};
/** ChatGPT sets its interface in the system face. */
const FONT = '-apple-system, system-ui, "Segoe UI", Helvetica, Arial, sans-serif';
/**
* A chat composer with starters, taking a prompt.
*
* Two things move and they move in sequence: the starters clear, then the
* send control fills in. Staggering them keeps the frame from changing in two
* places at once, which is what makes a composer shot read as deliberate
* rather than busy.
*/
export const ChatGpt = ({
prompt,
placeholder = "Ask anything",
suggestions = ["Summarize a document", "Analyze data", "Make a plan"],
logo,
theme = "dark",
charactersPerSecond = 26,
}: ChatGptProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const typed = useTyping(prompt, {from: 20, charactersPerSecond, chunk: 2});
const colors = PALETTE[theme];
const px = (value: number) => value * scale;
const enter = interpolate(frame, [0, 16], [0, 1], {easing: Easing.standard});
// The starters leave first, then the control arms: one change at a time.
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});
const onDark = theme === "dark";
return (
<Fill style={{alignItems: "center", background: colors.page, justifyContent: "center", padding: px(120)}}>
<div
style={{
maxWidth: px(1120),
opacity: enter,
transform: `translateY(${(1 - enter) * px(18)}px)`,
width: "100%",
}}
>
{logo ? <div style={{marginBottom: px(40), textAlign: "center"}}>{logo}</div> : null}
<div
style={{
alignItems: "center",
background: colors.field,
/* ChatGPT rings the composer with a shadow rather than a border:
a hairline at 4%, a short drop, and a very wide soft one. A
1px border reads harder than any of them. */
border: theme === "dark" ? `${px(1)}px solid ${colors.edge}` : undefined,
borderRadius: px(45),
boxShadow:
theme === "dark"
? undefined
: `0 0 0 ${px(1.6)}px rgba(0,0,0,0.04), 0 ${px(3)}px ${px(13)}px rgba(0,0,0,0.04), 0 ${px(6)}px ${px(128)}px ${px(13)}px rgba(0,0,0,0.02)`,
display: "flex",
gap: px(18),
padding: `${px(20)}px ${px(20)}px ${px(20)}px ${px(32)}px`,
}}
>
<span
style={{
color: typed.length > 0 ? colors.ink : colors.faint,
flex: 1,
fontFamily: FONT,
fontSize: px(32),
letterSpacing: "-0.01em",
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{typed.length > 0 ? typed.text : placeholder}
{typed.caret ? (
<span
style={{
background: colors.ink,
display: "inline-block",
height: px(32),
marginLeft: px(4),
transform: `translateY(${px(5)}px)`,
width: px(2),
}}
/>
) : null}
</span>
{/* The idle microphone gives way to a filled send control. */}
<span
style={{
alignItems: "center",
background: `color-mix(in srgb, ${colors.accent} ${armed * 100}%, transparent)`,
borderRadius: px(999),
display: "inline-flex",
flex: "none",
height: px(56),
justifyContent: "center",
position: "relative",
width: px(56),
}}
>
<svg viewBox="0 0 24 24" width={px(26)} height={px(26)} style={{opacity: 1 - armed, position: "absolute"}}>
<rect x="9" y="3" width="6" height="11" rx="3" fill={colors.faint} />
<path
d="M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3"
fill="none"
stroke={colors.faint}
strokeLinecap="round"
strokeWidth={1.8}
/>
</svg>
<svg
viewBox="0 0 24 24"
width={px(24)}
height={px(24)}
style={{opacity: armed, position: "absolute", transform: `scale(${0.7 + armed * 0.3})`}}
>
<path
d="M12 19V5M12 5l-6 6M12 5l6 6"
fill="none"
stroke={onDark ? "#0D0D0D" : "#FFFFFF"}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.4}
/>
</svg>
</span>
</div>
{suggestions.length > 0 ? (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: px(14),
justifyContent: "center",
marginTop: px(28),
opacity: 1 - cleared,
transform: `translateY(${cleared * px(-10)}px)`,
}}
>
{suggestions.map((suggestion) => (
<span
key={suggestion}
style={{
border: `${px(1)}px solid ${colors.edge}`,
borderRadius: px(999),
color: colors.faint,
fontFamily: FONT,
fontSize: px(22),
padding: `${px(12)}px ${px(22)}px`,
}}
>
{suggestion}
</span>
))}
</div>
) : null}
</div>
</Fill>
);
};
videos/components/chatgpt/chatgpt.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {ChatGpt} from "./chatgpt";
export default defineComponentPreview({
title: "ChatGPT",
category: "Agents",
description: "A ChatGPT composer with an animated prompt.",
component: ChatGpt,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
prompt: {type: "text", defaultValue: "Turn last quarter's numbers into a one page brief.", maxLength: 140},
placeholder: {type: "text", defaultValue: "Ask anything", maxLength: 48},
theme: {type: "select", defaultValue: "dark", options: ["dark", "light"]},
charactersPerSecond: {type: "number", defaultValue: 26, min: 8, max: 60, step: 1},
},
examples: [
{name: "Default", props: {}},
{name: "Light", props: {theme: "light"}},
{name: "No starters", props: {suggestions: []}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {Chatgpt} from "../components/chatgpt/chatgpt";
<Video>
<Scene id="chatgpt" duration="6s">
<Chatgpt
prompt="Turn last quarter's numbers into a one page brief."
placeholder="Ask anything"
theme="dark"
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
- 180 frames · 6s
- Minimum duration
- 90 frames · 3s
- Entrance
- 16 frames
- Exit
- 12 frames
- Reduced motion
- starters hidden rather than cleared
- Requires
- sans font
- Content limits
- prompt ≤ 140, placeholder ≤ 48
Props
| Prop | Type | Default | Required |
|---|---|---|---|
promptThe prompt that gets typed. | string | — | Yes |
placeholderWhat sits in the field before the first character. | string | "Ask anything" | — |
suggestionsStarters under the field. They clear as soon as typing starts, the way a suggestion stops being a suggestion once you have your own idea. | string[] | ["Summarize a document" | — |
logoA mark you have the right to use, drawn above the composer. | ReactNode | — | — |
theme | "dark" | "light" | "dark" | — |
charactersPerSecond | number | 26 | — |
Related components
All componentsClaudeA 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.v0A v0 composer with an animated prompt.Absolute stageFull-frame surface with stable clipping and positioning.