← Catalog
Edit Props (live)
TEXT
TEXT
TEXT
config
config
config
config
config
config
config
config
config
config
Source
691 linesimport React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
interpolate,
Easing,
spring,
} from "remotion";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
import { loadFont as loadMono } from "@remotion/google-fonts/JetBrainsMono";
loadInter("normal", { weights: ["400", "500", "600", "700", "800"] });
loadMono("normal", { weights: ["400", "500"] });
const FONT = "'Inter', sans-serif";
const MONO = "'JetBrains Mono', monospace";
const ACCENT = "#D97757";
interface Tab { label: string; active?: boolean }
interface FileRow {
name: string;
kind?: "folder" | "file";
depth?: number;
active?: boolean;
open?: boolean; // folder open chevron
ext?: string; // file extension for icon color
}
type ChatMode = "conversation" | "typing";
interface ClaudeCodeChatProps {
// Conversation-mode fields
userMessage: string;
aiHeading?: string;
aiResponse: string;
thoughtSeconds?: number;
// IDE chrome
tabs?: Tab[];
windowTitle?: string;
fileTree?: FileRow[];
showSidebar?: boolean;
placeholder?: string;
bypassLabel?: string;
// Animation
typingCps?: number;
mode?: ChatMode; // "conversation" (default) or "typing" (cursor → click → zoom → type)
backgroundColor?: string; // ignored — fixed dark
}
const DEFAULT_TABS: Tab[] = [
{ label: "tokens.css" },
{ label: "landing.tsx" },
{ label: "landing-v2.tsx", active: true },
{ label: "README.md" },
];
const DEFAULT_TREE: FileRow[] = [
{ name: "pipeline", kind: "folder", depth: 0, open: true },
{ name: "src", kind: "folder", depth: 1, open: true },
{ name: "keywords.ts", kind: "file", depth: 2, ext: "ts" },
{ name: "seo.ts", kind: "file", depth: 2, ext: "ts" },
{ name: "publish.ts", kind: "file", depth: 2, ext: "ts", active: true },
{ name: "package.json", kind: "file", depth: 1, ext: "json" },
{ name: "content", kind: "folder", depth: 0, open: true },
{ name: "drafts", kind: "folder", depth: 1 },
{ name: "published", kind: "folder", depth: 1 },
{ name: "README.md", kind: "file", depth: 0, ext: "md" },
];
const EXT_COLOR: Record<string, string> = {
ts: "#3178c6",
tsx: "#3178c6",
js: "#f7df1e",
json: "#facc15",
md: "#9ca3af",
css: "#38bdf8",
py: "#3776ab",
html: "#e34f26",
};
export const ClaudeCodeChat: React.FC<ClaudeCodeChatProps> = ({
userMessage,
aiHeading,
aiResponse,
thoughtSeconds,
tabs,
windowTitle = "claude-code — vscode",
fileTree,
showSidebar = true,
placeholder = "ctrl esc to focus or unfocus Claude",
bypassLabel = "Bypass permissions",
typingCps = 90,
mode = "conversation",
backgroundColor,
}) => {
void backgroundColor;
const frame = useCurrentFrame();
const { fps, width, height, durationInFrames } = useVideoConfig();
const t = frame / fps;
// 16:9 panel parked so it ends ~10px above the centered body-caption band.
const panelW = width;
const panelH = Math.round(panelW * 9 / 16);
const panelTop = Math.round(height * 0.15);
// Conversation timings (mode = conversation)
const userSpring = spring({ frame: frame - 6, fps, config: { mass: 0.6, damping: 14, stiffness: 200 } });
const userOp = interpolate(userSpring, [0, 1], [0, 1]);
const userY = interpolate(userSpring, [0, 1], [18, 0]);
const thoughtOp = interpolate(frame, [16, 24], [0, 1], { extrapolateRight: "clamp" });
const headingOp = interpolate(frame, [22, 30], [0, 1], { extrapolateRight: "clamp" });
const headingY = interpolate(frame, [22, 30], [12, 0], { extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
const streamStart = 26;
const revealCount = Math.max(0, Math.floor(((frame - streamStart) / fps) * typingCps));
const streamed = aiResponse.slice(0, revealCount);
const isStreaming = revealCount < aiResponse.length;
const cursorBlink = isStreaming && frame % 18 < 9;
// Typing-mode timings: scale to the scene's actual duration so even a
// ~1.5s scene shows the cursor land + at least the first half of the typed
// text. Fractions of durationInFrames; typingCps auto-derives so the
// message finishes ~95% of the way through the scene.
const D = durationInFrames;
const T_CURSOR_IN_F = Math.round(D * 0.04);
const T_CURSOR_AT_F = Math.round(D * 0.22);
const T_CLICK_F = Math.round(D * 0.27);
const T_ZOOM_START_F = Math.round(D * 0.30);
const T_ZOOM_END_F = Math.round(D * 0.50);
const T_TYPE_START_F = Math.round(D * 0.32); // start typing as soon as click fires
const T_TYPE_END_F = Math.round(D * 0.95);
const typingWindowFrames = Math.max(1, T_TYPE_END_F - T_TYPE_START_F);
const charsPerFrame = Math.max(0.4, userMessage.length / typingWindowFrames);
const typingChars = Math.max(0, Math.floor((frame - T_TYPE_START_F) * charsPerFrame));
const typedText = userMessage.slice(0, typingChars);
const typingCaret = typingChars < userMessage.length && frame % 18 < 9;
// Camera zoom for typing mode — zooms toward the composer's position.
// Composer sits at the bottom of the panel; transform-origin pulls toward it.
const zoomT = interpolate(frame, [T_ZOOM_START_F, T_ZOOM_END_F], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
easing: Easing.inOut(Easing.cubic),
});
const zoomScale = mode === "typing" ? interpolate(zoomT, [0, 1], [1, 1.6]) : 1;
const chromeOp = interpolate(t, [0, 0.25], [0, 1], { extrapolateRight: "clamp" });
const chromeScale = interpolate(t, [0, 0.25], [0.97, 1], {
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const tabList = tabs && tabs.length ? tabs : DEFAULT_TABS;
const tree = fileTree && fileTree.length ? fileTree : DEFAULT_TREE;
const sidebarW = showSidebar ? Math.round(panelW * 0.22) : 0;
return (
<AbsoluteFill style={{ backgroundColor: "#0d0d0f", overflow: "hidden", fontFamily: FONT }}>
{/* 16:9 IDE panel anchored upper-third */}
<div
style={{
position: "absolute",
top: panelTop,
left: 0,
width: panelW,
height: panelH,
opacity: chromeOp,
transform: `scale(${chromeScale * zoomScale})`,
transformOrigin: "50% 100%", // anchor zoom to composer (bottom)
}}
>
<div
style={{
position: "absolute",
inset: 0,
background: "#1e1e1e",
borderRadius: 16,
overflow: "hidden",
boxShadow: "0 30px 80px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.08)",
display: "flex",
flexDirection: "column",
}}
>
{/* Title bar */}
<div
style={{
height: 36,
background: "linear-gradient(to bottom, #2a2a2a, #1f1f1f)",
borderBottom: "1px solid rgba(255,255,255,0.06)",
display: "flex",
alignItems: "center",
padding: "0 14px",
gap: 8,
flexShrink: 0,
}}
>
<div style={{ width: 11, height: 11, borderRadius: 6, background: "#ff5f57" }} />
<div style={{ width: 11, height: 11, borderRadius: 6, background: "#febc2e" }} />
<div style={{ width: 11, height: 11, borderRadius: 6, background: "#28c840" }} />
<div style={{ flex: 1, textAlign: "center", fontFamily: MONO, fontSize: 14, color: "rgba(255,255,255,0.55)" }}>
{windowTitle}
</div>
<div style={{ width: 50 }} />
</div>
{/* Main row: sidebar + (tabs + body + composer) */}
<div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0 }}>
{/* Sidebar */}
{showSidebar && (
<Sidebar width={sidebarW} tree={tree} />
)}
{/* Right column */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0, background: "#1e1e1e" }}>
{/* Tab bar */}
<div
style={{
display: "flex",
background: "#1f1f1f",
borderBottom: "1px solid rgba(255,255,255,0.06)",
height: 30,
flexShrink: 0,
}}
>
{tabList.slice(0, 4).map((tab, i) => {
const active = tab.active || (i === Math.min(2, tabList.length - 1) && !tabList.some(x => x.active));
return (
<div
key={i}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "0 12px",
background: active ? "#1e1e1e" : "transparent",
borderRight: "1px solid rgba(255,255,255,0.05)",
borderTop: active ? `2px solid ${ACCENT}` : "2px solid transparent",
fontSize: 12,
color: active ? "#d4d4d4" : "rgba(255,255,255,0.45)",
fontFamily: MONO,
maxWidth: 200,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
<span style={{ width: 7, height: 7, borderRadius: 2, background: ACCENT, flexShrink: 0 }} />
{tab.label}
{active && <span style={{ marginLeft: 4, color: "rgba(255,255,255,0.45)" }}>×</span>}
</div>
);
})}
<div style={{ flex: 1 }} />
</div>
{/* Body */}
<div
style={{
flex: 1,
padding: "16px 22px 12px 22px",
color: "#d4d4d4",
display: "flex",
flexDirection: "column",
gap: 12,
overflow: "hidden",
fontSize: 14,
lineHeight: 1.45,
minHeight: 0,
}}
>
{mode === "conversation" ? (
<ConversationBody
userMessage={userMessage}
userOp={userOp}
userY={userY}
thoughtSeconds={thoughtSeconds}
thoughtOp={thoughtOp}
aiHeading={aiHeading}
headingOp={headingOp}
headingY={headingY}
streamed={streamed}
cursorBlink={cursorBlink}
/>
) : (
// Typing-mode body: keep it sparse so the composer is the focus
<div style={{ opacity: 0.5, color: "rgba(255,255,255,0.35)", fontSize: 13 }}>
No conversation yet — type a prompt to begin.
</div>
)}
</div>
{/* Composer */}
<Composer
placeholder={placeholder}
bypassLabel={bypassLabel}
typedText={typedText}
typingCaret={typingCaret}
mode={mode}
clickFlash={mode === "typing" ? interpolate(frame, [T_CLICK_F - 2, T_CLICK_F + 4, T_CLICK_F + 12], [0, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) : 0}
/>
</div>
</div>
</div>
</div>
{/* Typing-mode cursor — drawn ABOVE the IDE in screen space so it isn't
affected by the panel's zoom transform. */}
{mode === "typing" && (
<CursorOverlay
frame={frame}
fps={fps}
enterFrame={T_CURSOR_IN_F}
arriveFrame={T_CURSOR_AT_F}
clickFrame={T_CLICK_F}
fadeOutFrame={T_ZOOM_END_F + 4}
panelTop={panelTop}
panelW={panelW}
panelH={panelH}
sidebarW={sidebarW}
/>
)}
</AbsoluteFill>
);
};
// ─── Sidebar ─────────────────────────────────────────────────────────────────
const Sidebar: React.FC<{ width: number; tree: FileRow[] }> = ({ width, tree }) => (
<div
style={{
width,
background: "#181818",
borderRight: "1px solid rgba(255,255,255,0.06)",
padding: "10px 0",
display: "flex",
flexDirection: "column",
flexShrink: 0,
overflow: "hidden",
}}
>
<div
style={{
fontSize: 11, color: "rgba(255,255,255,0.55)",
fontWeight: 700, letterSpacing: 0.6, textTransform: "uppercase",
padding: "0 14px 8px 14px", fontFamily: FONT,
}}
>
Explorer
</div>
{tree.map((row, i) => (
<FileTreeRow key={i} row={row} />
))}
</div>
);
const FileTreeRow: React.FC<{ row: FileRow }> = ({ row }) => {
const isFolder = row.kind === "folder";
const indent = (row.depth ?? 0) * 14;
const color = row.active
? "#ffffff"
: isFolder
? "rgba(255,255,255,0.78)"
: "rgba(255,255,255,0.65)";
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: `3px 10px 3px ${10 + indent}px`,
background: row.active ? "rgba(255,255,255,0.06)" : "transparent",
borderLeft: row.active ? `2px solid ${ACCENT}` : "2px solid transparent",
fontFamily: MONO,
fontSize: 12,
color,
}}
>
{isFolder && (
<span style={{ width: 10, color: "rgba(255,255,255,0.5)", textAlign: "center", fontSize: 10 }}>
{row.open ? "▾" : "▸"}
</span>
)}
{!isFolder && (
<span
style={{
width: 10, height: 10, borderRadius: 2,
background: EXT_COLOR[row.ext || "ts"] || "#9ca3af",
flexShrink: 0,
}}
/>
)}
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{row.name}
</span>
</div>
);
};
// ─── Conversation body ──────────────────────────────────────────────────────
const ConversationBody: React.FC<{
userMessage: string;
userOp: number;
userY: number;
thoughtSeconds?: number;
thoughtOp: number;
aiHeading?: string;
headingOp: number;
headingY: number;
streamed: string;
cursorBlink: boolean;
}> = ({ userMessage, userOp, userY, thoughtSeconds, thoughtOp, aiHeading, headingOp, headingY, streamed, cursorBlink }) => (
<>
{/* User message */}
<div
style={{
opacity: userOp,
transform: `translateY(${userY}px)`,
border: "1px solid rgba(255,255,255,0.10)",
borderRadius: 8,
background: "#252525",
padding: "10px 12px",
fontSize: 14,
color: "#d4d4d4",
lineHeight: 1.4,
}}
>
{userMessage}
</div>
{/* AI block */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{thoughtSeconds != null && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
opacity: thoughtOp,
fontSize: 11,
color: "rgba(255,255,255,0.55)",
}}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "rgba(255,255,255,0.35)" }} />
Thought for {thoughtSeconds}s
</div>
)}
{aiHeading && (
<div
style={{
fontWeight: 800,
fontSize: 14,
letterSpacing: 0.4,
color: "#ffffff",
opacity: headingOp,
transform: `translateY(${headingY}px)`,
}}
>
{aiHeading}
</div>
)}
<div style={{ fontSize: 14, color: "#d4d4d4", lineHeight: 1.5 }}>
{parseSimpleMarkdown(streamed)}
{cursorBlink && <span style={{ color: ACCENT, marginLeft: 2 }}>▊</span>}
</div>
</div>
</>
);
// ─── Composer ────────────────────────────────────────────────────────────────
const Composer: React.FC<{
placeholder: string;
bypassLabel: string;
typedText: string;
typingCaret: boolean;
mode: ChatMode;
clickFlash: number;
}> = ({ placeholder, bypassLabel, typedText, typingCaret, mode, clickFlash }) => (
<div
style={{
borderTop: "1px solid rgba(255,255,255,0.06)",
padding: "10px 14px 12px 14px",
display: "flex",
flexDirection: "column",
gap: 8,
flexShrink: 0,
position: "relative",
}}
>
{/* Click flash ring */}
{clickFlash > 0 && (
<div
style={{
position: "absolute",
inset: 6,
borderRadius: 14,
border: `2px solid ${ACCENT}`,
opacity: clickFlash,
pointerEvents: "none",
}}
/>
)}
<div
style={{
border: `1px solid ${ACCENT}77`,
boxShadow: `0 0 0 2px ${ACCENT}22 inset`,
borderRadius: 12,
padding: "12px 14px",
fontSize: 13,
color: typedText ? "#ffffff" : "rgba(255,255,255,0.45)",
minHeight: 18,
background: "rgba(255,255,255,0.03)",
}}
>
{mode === "typing" && typedText ? (
<>
{typedText}
{typingCaret && <span style={{ color: ACCENT, marginLeft: 1 }}>▊</span>}
</>
) : (
placeholder
)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<CornerBtn glyph="+" />
<CornerBtn glyph="/" />
<div style={{ flex: 1 }} />
<div
style={{
fontSize: 11,
color: "rgba(255,255,255,0.55)",
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.10)",
borderRadius: 6,
padding: "5px 10px",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: ACCENT }} />
{bypassLabel}
</div>
<div
style={{
width: 28, height: 28,
borderRadius: 6,
background: `${ACCENT}33`,
border: `1px solid ${ACCENT}88`,
color: ACCENT,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 14,
}}
>
↑
</div>
</div>
</div>
);
const CornerBtn: React.FC<{ glyph: string }> = ({ glyph }) => (
<div
style={{
width: 26, height: 26, borderRadius: 6,
border: "1px solid rgba(255,255,255,0.10)",
background: "rgba(255,255,255,0.04)",
color: "rgba(255,255,255,0.55)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 14,
}}
>
{glyph}
</div>
);
// ─── Cursor overlay ──────────────────────────────────────────────────────────
const CursorOverlay: React.FC<{
frame: number;
fps: number;
enterFrame: number;
arriveFrame: number;
clickFrame: number;
fadeOutFrame: number;
panelTop: number;
panelW: number;
panelH: number;
sidebarW: number;
}> = ({ frame, enterFrame, arriveFrame, clickFrame, fadeOutFrame, panelTop, panelW, panelH, sidebarW }) => {
// Composer sits at the bottom of the panel; aim for ~60% width of the
// composer pill, ~44px from the panel bottom.
const composerCenterX = sidebarW + (panelW - sidebarW) * 0.45;
const composerCenterY = panelTop + panelH - 60;
const startX = panelW + 80;
const startY = panelTop + panelH * 0.35;
const t = interpolate(frame, [enterFrame, arriveFrame], [0, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
easing: Easing.inOut(Easing.cubic),
});
const x = startX + (composerCenterX - startX) * t;
const y = startY + (composerCenterY - startY) * t;
// Click squish at click frame
const clickT = interpolate(frame, [clickFrame - 2, clickFrame, clickFrame + 6], [1, 0.82, 1], {
extrapolateLeft: "clamp", extrapolateRight: "clamp",
});
const opIn = interpolate(frame, [enterFrame - 2, enterFrame + 4], [0, 1], { extrapolateRight: "clamp" });
const opOut = interpolate(frame, [fadeOutFrame, fadeOutFrame + 8], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const op = Math.min(opIn, opOut);
return (
<div
style={{
position: "absolute",
left: x - 16,
top: y - 8,
opacity: op,
transform: `scale(${clickT})`,
transformOrigin: "8px 4px",
pointerEvents: "none",
filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.45))",
}}
>
{/* Mac-style arrow cursor */}
<svg width="32" height="44" viewBox="0 0 32 44" fill="none">
<path
d="M3 2 L3 32 L11 24 L16 38 L21 36 L16 22 L26 22 Z"
fill="#ffffff"
stroke="#000000"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
</div>
);
};
// ─── Markdown helpers ───────────────────────────────────────────────────────
function parseSimpleMarkdown(text: string): React.ReactNode {
if (!text) return null;
const lines = text.split("\n");
const blocks: React.ReactNode[] = [];
let bullets: string[] = [];
const flushBullets = () => {
if (!bullets.length) return;
blocks.push(
<ul key={`b${blocks.length}`} style={{ margin: "4px 0 0 0", paddingLeft: 20 }}>
{bullets.map((b, i) => (
<li key={i} style={{ marginBottom: 3 }}>{renderInline(b)}</li>
))}
</ul>,
);
bullets = [];
};
for (const raw of lines) {
const line = raw.trimEnd();
const m = line.match(/^\s*[-*]\s+(.*)$/);
if (m) {
bullets.push(m[1]);
} else {
flushBullets();
if (line) {
blocks.push(
<div key={`p${blocks.length}`} style={{ marginBottom: 4 }}>{renderInline(line)}</div>,
);
} else {
blocks.push(<div key={`g${blocks.length}`} style={{ height: 4 }} />);
}
}
}
flushBullets();
return blocks;
}
function renderInline(text: string): React.ReactNode {
const parts = text.split(/(\*\*[^*]+\*\*)/g);
return parts.map((p, i) => {
if (p.startsWith("**") && p.endsWith("**")) {
return <strong key={i} style={{ color: "#ffffff" }}>{p.slice(2, -2)}</strong>;
}
return <span key={i}>{p}</span>;
});
}
Schema (Zod)
// Auto-extracted from Props interface — see source for authoritative types.
// Component: ClaudeCodeChat
// 13 props detected · 3 port(s)
Claude Code Chat
claude_code_chatClaude Code Chat — Andy's hand-crafted Remotion scene. 13 props. userMessage:TEXT, aiResponse:TEXT, windowTitle:TEXT.
Workflow Inputs (3)
- User Message
userMessageTEXTRequired - Ai Response
aiResponseTEXTRequired - Window Title
windowTitleTEXT
Config Fields (10)
modetabsfileTreeaiHeadingtypingCpsbypassLabelplaceholdershowSidebarthoughtSecondsbackgroundColor
Meta
- Updated
- 4/21/2026