← Catalog
Edit Props (live)
TEXT
IMAGE
config
config
config
config
config
config
config
config
config
Source
347 linesimport React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
interpolate,
Easing,
spring,
} from "remotion";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
loadInter("normal", { weights: ["400", "500", "600", "700", "800"] });
interface PromptResponseProps {
userPrompt: string;
aiResponse: string;
userName?: string; // "You"
aiName?: string; // "Claude" / "ChatGPT"
aiAvatar?: string; // (unused — Claude sun mark drawn inline)
aiAccent?: string; // accent for the send button + spark icon
typingCps?: number; // chars per second for streamed reply (default 110)
backgroundColor?: string; // chat surface color (default Claude cream)
toolLabel?: string; // text inside the tool-call pill (default "Fetching page, Writing")
bubbleUserBg?: string; // accepted for back-compat, ignored in new layout
bubbleAiBg?: string; // accepted for back-compat, ignored in new layout
}
const FONT = "'Inter', sans-serif";
// Approximation of the actual Claude product UI: cream surface, "Chat / Comments"
// tab header, plain "You" + "Claude" labelled paragraphs, a tool-call pill with the
// orange spark mark, and a bottom composer with the orange send button.
export const PromptResponse: React.FC<PromptResponseProps> = ({
userPrompt,
aiResponse,
userName = "You",
aiName = "Claude",
aiAccent = "#D97757",
typingCps = 110,
backgroundColor, // accepted but ignored — we always render the Claude cream surface for visual consistency
toolLabel = "Fetching page, Writing",
}) => {
void backgroundColor;
const surfaceBg = "#F5F0E8";
const outerBg = "#E5E0D6";
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Sequence:
// 0–6f : surface fade-in
// 2f+ : "You" block springs in
// 10f+ : "Claude" label appears, tool pill slides in
// 14f+ : reply text streams character-by-character
const surfaceOp = interpolate(frame, [0, 6], [0, 1], { extrapolateRight: "clamp" });
const youSpring = spring({ frame: frame - 2, fps, config: { mass: 0.6, damping: 14, stiffness: 220 } });
const youOp = interpolate(youSpring, [0, 1], [0, 1]);
const youY = interpolate(youSpring, [0, 1], [22, 0]);
const claudeOp = interpolate(frame, [10, 16], [0, 1], { extrapolateRight: "clamp" });
const claudeY = interpolate(frame, [10, 16], [16, 0], {
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const replyStart = 14;
const revealCount = Math.max(
0,
Math.floor(((frame - replyStart) / fps) * typingCps),
);
const streamedReply = aiResponse.slice(0, revealCount);
const isStreaming = revealCount < aiResponse.length;
const cursorBlink = isStreaming && frame % 18 < 9;
// Lines starting with "- " or "* " render as bullets; everything else is a paragraph.
const renderedBlocks = parseSimpleMarkdown(streamedReply);
return (
<AbsoluteFill style={{ backgroundColor: outerBg, overflow: "hidden", fontFamily: FONT }}>
{/* Chat surface — vertically biased UPWARD so the captions don't cover it. */}
<div
style={{
position: "absolute",
left: 36,
right: 36,
top: 64,
bottom: 360, // leave generous room at bottom for kinetic captions
background: surfaceBg,
borderRadius: 28,
boxShadow: "0 36px 80px rgba(0,0,0,0.18), 0 8px 24px rgba(0,0,0,0.10)",
opacity: surfaceOp,
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
{/* Tab header */}
<ChatHeader />
{/* Conversation body */}
<div
style={{
flex: 1,
padding: "26px 38px 18px 38px",
display: "flex",
flexDirection: "column",
gap: 24,
overflow: "hidden",
}}
>
{/* You */}
<div style={{ opacity: youOp, transform: `translateY(${youY}px)` }}>
<Label>{userName}</Label>
<div style={messageStyle}>{userPrompt}</div>
</div>
{/* Claude */}
<div style={{ opacity: claudeOp, transform: `translateY(${claudeY}px)` }}>
<Label>{aiName}</Label>
<ToolPill label={toolLabel} accent={aiAccent} frame={frame} fps={fps} />
<div style={{ ...messageStyle, marginTop: 14 }}>
{renderedBlocks}
{cursorBlink && <span style={{ color: aiAccent, marginLeft: 2 }}>▊</span>}
</div>
</div>
</div>
{/* Composer */}
<Composer accent={aiAccent} />
</div>
</AbsoluteFill>
);
};
// ─── Subcomponents ───────────────────────────────────────────────────────────
const Label: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div style={{ fontSize: 30, fontWeight: 700, color: "#1f1f1f", marginBottom: 10 }}>
{children}
</div>
);
const messageStyle: React.CSSProperties = {
fontSize: 32,
fontWeight: 400,
color: "#1f1f1f",
lineHeight: 1.42,
letterSpacing: -0.2,
whiteSpace: "pre-wrap",
};
const ChatHeader: React.FC = () => (
<div
style={{
height: 76,
display: "flex",
alignItems: "stretch",
borderBottom: "1px solid rgba(0,0,0,0.08)",
paddingLeft: 24,
paddingRight: 24,
gap: 0,
}}
>
<Tab label="Chat" active />
<Tab label="Comments" />
<div style={{ flex: 1 }} />
<div
style={{
alignSelf: "center",
width: 40, height: 40, borderRadius: 10,
display: "flex", alignItems: "center", justifyContent: "center",
color: "#777", fontSize: 26, fontWeight: 600,
}}
>
+
</div>
</div>
);
const Tab: React.FC<{ label: string; active?: boolean }> = ({ label, active }) => (
<div
style={{
display: "flex",
alignItems: "center",
padding: "0 22px",
fontSize: 24,
fontWeight: active ? 700 : 500,
color: active ? "#1f1f1f" : "#888",
borderBottom: active ? "3px solid #1f1f1f" : "3px solid transparent",
marginBottom: -1,
}}
>
{label}
</div>
);
const ToolPill: React.FC<{ label: string; accent: string; frame: number; fps: number }> = ({
label, accent, frame, fps,
}) => {
const enter = spring({ frame: frame - 12, fps, config: { mass: 0.6, damping: 14, stiffness: 220 } });
const op = interpolate(enter, [0, 1], [0, 1]);
const x = interpolate(enter, [0, 1], [-30, 0]);
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 12,
background: "#FFFFFF",
border: "1px solid rgba(0,0,0,0.08)",
borderRadius: 14,
padding: "12px 18px",
opacity: op,
transform: `translateX(${x}px)`,
boxShadow: "0 1px 0 rgba(0,0,0,0.03)",
}}
>
<ClaudeSpark color={accent} size={22} />
<span style={{ fontSize: 24, color: "#333", fontWeight: 500 }}>{label}</span>
<span style={{ fontSize: 22, color: "#888", marginLeft: 4 }}>⌄</span>
</div>
);
};
// Simplified Claude wordmark spark — orange sun-burst icon.
const ClaudeSpark: React.FC<{ color: string; size: number }> = ({ color, size }) => {
const rays = 8;
const cx = size / 2, cy = size / 2;
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{Array.from({ length: rays }).map((_, i) => {
const angle = (i / rays) * Math.PI * 2;
const x1 = cx + Math.cos(angle) * (size * 0.18);
const y1 = cy + Math.sin(angle) * (size * 0.18);
const x2 = cx + Math.cos(angle) * (size * 0.46);
const y2 = cy + Math.sin(angle) * (size * 0.46);
return (
<line
key={i}
x1={x1} y1={y1} x2={x2} y2={y2}
stroke={color}
strokeWidth={size * 0.12}
strokeLinecap="round"
/>
);
})}
</svg>
);
};
const Composer: React.FC<{ accent: string }> = ({ accent }) => (
<div
style={{
borderTop: "1px solid rgba(0,0,0,0.08)",
padding: "18px 22px 22px 22px",
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
<div
style={{
background: "#FFFFFF",
border: "1px solid rgba(0,0,0,0.10)",
borderRadius: 22,
padding: "20px 24px",
fontSize: 28,
color: "#888",
minHeight: 30,
}}
>
Reply…
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<CircleIcon glyph="⚙" />
<CircleIcon glyph="📎" />
<CircleIcon glyph="🎙" />
<div style={{ flex: 1 }} />
<div
style={{
background: accent,
color: "#fff",
fontSize: 24,
fontWeight: 600,
padding: "14px 28px",
borderRadius: 999,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<span style={{ fontSize: 20 }}>▶</span> Send
</div>
</div>
</div>
);
const CircleIcon: React.FC<{ glyph: string }> = ({ glyph }) => (
<div
style={{
width: 48, height: 48, borderRadius: 12,
border: "1px solid rgba(0,0,0,0.10)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 22, color: "#555",
background: "#fff",
}}
>
{glyph}
</div>
);
// ─── Markdown helper ─────────────────────────────────────────────────────────
function parseSimpleMarkdown(text: string): React.ReactNode {
if (!text) return null;
const lines = text.split("\n");
const blocks: React.ReactNode[] = [];
let bulletGroup: string[] = [];
const flushBullets = () => {
if (!bulletGroup.length) return;
blocks.push(
<ul key={`b${blocks.length}`} style={{ margin: "8px 0 0 0", paddingLeft: 28 }}>
{bulletGroup.map((b, i) => (
<li key={i} style={{ marginBottom: 6 }}>{b}</li>
))}
</ul>,
);
bulletGroup = [];
};
for (const raw of lines) {
const line = raw.trimEnd();
const m = line.match(/^\s*[-*]\s+(.*)$/);
if (m) {
bulletGroup.push(m[1]);
} else {
flushBullets();
if (line) {
blocks.push(
<div key={`p${blocks.length}`} style={{ marginBottom: 6 }}>{line}</div>,
);
}
}
}
flushBullets();
return blocks;
}
Schema (Zod)
// Auto-extracted from Props interface — see source for authoritative types.
// Component: PromptResponse
// 11 props detected · 2 port(s)
Prompt Response
prompt_responsePrompt Response — Andy's hand-crafted Remotion scene. 11 props. aiResponse:TEXT, aiAvatar:IMAGE.
Workflow Inputs (2)
- Ai Response
aiResponseTEXTRequired - Ai Avatar
aiAvatarIMAGE
Config Fields (9)
aiNameaiAccentuserNametoolLabeltypingCpsbubbleAiBguserPromptbubbleUserBgbackgroundColor
Meta
- Updated
- 4/21/2026