AI Chat
Animated AI chat walkthrough with typing effects, perfect for showcasing conversational product experiences.
Preview
Generate with AI
Want to create this component using AI? Copy the prompt below and paste it into any LLM (ChatGPT, Claude, etc.):
Installation
npx shadcn@latest add https://uiregistry.cappychat.com/registry/ai-chat.json Install dependencies
npm i framer-motion lucide-react react-shiki shiki clsx tailwind-mergeAdd util file
lib/utils.ts
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}Copy the source code
components/ui/ai-chat.tsx
src/components/AIapplicationsComponents/ai-chat.tsx
"use client";
import React, {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { motion, useInView } from "framer-motion";
import { Bot, MessageSquareText, User } from "lucide-react";
import ShikiHighlighter from "react-shiki";
import { cn } from "@/lib/utils";
// Optimized: Memoized theme detection with reduced re-renders
function useThemeDetection() {
const [isDarkMode, setIsDarkMode] = useState(() => {
if (typeof document === "undefined") return false;
const root = document.documentElement;
return root.classList.contains("dark");
});
useEffect(() => {
const computeIsDark = () => {
const root = document.documentElement;
return root.classList.contains("dark");
};
const observer = new MutationObserver(() => {
const isDark = computeIsDark();
setIsDarkMode((prev) => (prev !== isDark ? isDark : prev)); // Only update if changed
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"], // Only watch class changes
});
return () => observer.disconnect();
}, []);
return isDarkMode;
}
export interface ChatDemoMessage {
role: "user" | "assistant";
content: string;
timestamp: string;
thinkingDelay?: number;
nextDelay?: number;
}
const DEMO_MESSAGES: ChatDemoMessage[] = [
{
role: "user",
content: "What is React?",
timestamp: "10:24 AM",
thinkingDelay: 1500,
},
{
role: "assistant",
content:
"React is a JavaScript library for building user interfaces. It lets you create reusable components and efficiently update the UI when your data changes. It's maintained by Meta and widely used for web applications.",
timestamp: "10:24 AM",
nextDelay: 1200,
},
{
role: "user",
content: "Can you show me a simple example?",
timestamp: "10:26 AM",
thinkingDelay: 1200,
},
{
role: "assistant",
content:
'Sure! Here\'s a basic React component:\n\n```tsx\nfunction Welcome({ name }) {\n return <h1>Hello, {name}!</h1>;\n}```\n\nYou can use it like this: `<Welcome name="Sarah" />` and it will display "Hello, Sarah!"',
timestamp: "10:27 AM",
nextDelay: 1200,
},
{
role: "user",
content: "That's helpful! What makes React so popular?",
timestamp: "10:28 AM",
thinkingDelay: 1200,
},
{
role: "assistant",
content:
"React is popular because it's simple to learn, has a huge community, and makes building interactive UIs easy. The component-based approach helps you break down complex interfaces into smaller, manageable pieces. Plus, it has great performance and works well with other tools!",
timestamp: "10:29 AM",
nextDelay: 1200,
},
{
role: "user",
content: "Thanks! This makes sense now.",
timestamp: "10:30 AM",
thinkingDelay: 1000,
},
{
role: "assistant",
content:
"You're welcome! Feel free to ask if you have more questions. Happy coding! 🚀",
timestamp: "10:30 AM",
nextDelay: 1200,
},
];
interface TypingTextProps {
text: string;
isVisible: boolean;
delay?: number;
speed?: number;
onComplete?: () => void;
}
const TypingText = ({
text,
isVisible,
delay = 0,
speed = 20,
onComplete,
}: TypingTextProps) => {
const [displayedText, setDisplayedText] = useState("");
const [currentIndex, setCurrentIndex] = useState(0);
const [hasStarted, setHasStarted] = useState(false);
useEffect(() => {
if (!isVisible) {
setDisplayedText("");
setCurrentIndex(0);
setHasStarted(false);
return;
}
if (!hasStarted) {
setHasStarted(true);
setDisplayedText("");
setCurrentIndex(0);
}
}, [isVisible, hasStarted]);
useEffect(() => {
if (!isVisible || !hasStarted) return;
const timer = window.setTimeout(
() => {
if (currentIndex < text.length) {
setDisplayedText((prev) => prev + text[currentIndex]);
setCurrentIndex((prev) => prev + 1);
} else if (onComplete && currentIndex === text.length) {
onComplete();
}
},
currentIndex === 0 ? delay : speed
);
return () => window.clearTimeout(timer);
}, [isVisible, hasStarted, currentIndex, text, delay, speed, onComplete]);
return <span className="">{displayedText}</span>;
};
interface TypingCodeBlockProps {
code: string;
isVisible: boolean;
delay?: number;
speed?: number;
onComplete?: () => void;
isDarkMode: boolean;
}
const TypingCodeBlock = ({
code,
isVisible,
delay = 0,
speed = 35,
onComplete,
isDarkMode,
}: TypingCodeBlockProps) => {
const [displayedCode, setDisplayedCode] = useState("");
const [currentIndex, setCurrentIndex] = useState(0);
const [hasStarted, setHasStarted] = useState(false);
useEffect(() => {
if (!isVisible) {
setDisplayedCode("");
setCurrentIndex(0);
setHasStarted(false);
return;
}
if (!hasStarted) {
setHasStarted(true);
setDisplayedCode("");
setCurrentIndex(0);
}
}, [isVisible, hasStarted]);
useEffect(() => {
if (!isVisible || !hasStarted) return;
const timer = window.setTimeout(
() => {
if (currentIndex < code.length) {
// Batch 2 characters at a time for smoother animation
const charsToAdd = Math.min(2, code.length - currentIndex);
setDisplayedCode((prev) => prev + code.slice(currentIndex, currentIndex + charsToAdd));
setCurrentIndex((prev) => prev + charsToAdd);
} else if (onComplete && currentIndex >= code.length) {
onComplete();
}
},
currentIndex === 0 ? delay : speed
);
return () => window.clearTimeout(timer);
}, [isVisible, hasStarted, currentIndex, code, delay, speed, onComplete]);
return (
<div className="rounded-lg border border-muted-foreground/20 bg-muted/40 dark:bg-background/40 overflow-x-auto">
<ShikiHighlighter
language="tsx"
theme={isDarkMode ? "github-dark" : "github-light"}
className="text-sm font-mono overflow-x-auto bg-transparent min-w-0 max-w-full"
showLanguage={false}
>
{displayedCode || " "}
</ShikiHighlighter>
</div>
);
};
// Optimized: Memoized component for static code blocks
const StaticCodeBlock = memo(
({
code,
language,
isDarkMode,
}: {
code: string;
language: string;
isDarkMode: boolean;
}) => (
<div className="my-3">
<div className="rounded-lg border border-muted-foreground/20 bg-muted/40 dark:bg-background/40 overflow-x-auto">
<ShikiHighlighter
language={language}
theme={isDarkMode ? "github-dark" : "github-light"}
className="text-sm font-mono overflow-x-auto bg-transparent min-w-0 max-w-full"
showLanguage={false}
>
{code}
</ShikiHighlighter>
</div>
</div>
)
);
StaticCodeBlock.displayName = "StaticCodeBlock";
const renderStaticSegments = (content: string, isDarkMode: boolean) => {
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
// Create new regex instance to avoid global state issues
const codeBlockRegex = /```(tsx|jsx|javascript|js)?\n([\s\S]*?)```/g;
while ((match = codeBlockRegex.exec(content)) !== null) {
if (match.index > lastIndex) {
const textPortion = content.slice(lastIndex, match.index).trim();
if (textPortion) {
nodes.push(
<span key={`text-${nodes.length}`} className="mb-2 leading-relaxed ">
{renderMarkdown(textPortion)}
</span>
);
}
}
const codeContent = match[2];
const language = match[1] || "tsx";
nodes.push(
<StaticCodeBlock
key={`code-${nodes.length}`}
code={codeContent}
language={language}
isDarkMode={isDarkMode}
/>
);
lastIndex = match.index + match[0].length;
}
if (lastIndex < content.length) {
const remaining = content.slice(lastIndex).trim();
if (remaining) {
nodes.push(
<span key={`text-${nodes.length}`} className="leading-relaxed ">
{renderMarkdown(remaining)}
</span>
);
}
}
return <>{nodes}</>;
};
// Helper function to render markdown (bold, inline code)
const renderMarkdown = (text: string) => {
const parts: React.ReactNode[] = [];
let lastIndex = 0;
// Match **bold** and `inline code`
const regex = /(\*\*(.+?)\*\*)|(`(.+?)`)/g;
let match;
while ((match = regex.exec(text)) !== null) {
// Add text before match
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
if (match[2]) {
// Bold text
parts.push(
<strong key={match.index} className="font-semibold text-foreground">
{match[2]}
</strong>
);
} else if (match[4]) {
// Inline code
parts.push(
<code
key={match.index}
className="px-1.5 py-0.5 rounded bg-muted text-foreground font-mono text-xs"
>
{match[4]}
</code>
);
}
lastIndex = match.index + match[0].length;
}
// Add remaining text
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : text;
};
// Optimized: Memoized content splitting with LRU cache
class LRUCache<K, V> {
private cache = new Map<K, V>();
private maxSize: number;
constructor(maxSize: number = 50) {
this.maxSize = maxSize;
}
get(key: K): V | undefined {
const value = this.cache.get(key);
if (value !== undefined) {
// Move to end (most recently used)
this.cache.delete(key);
this.cache.set(key, value);
}
return value;
}
set(key: K, value: V): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.maxSize) {
// Remove least recently used (first item)
const iterator = this.cache.keys();
const firstResult = iterator.next();
if (!firstResult.done) {
this.cache.delete(firstResult.value);
}
}
this.cache.set(key, value);
}
clear(): void {
this.cache.clear();
}
}
const contentCache = new LRUCache<
string,
Array<{ type: "text" | "code"; value: string; delay: number }>
>();
const splitContent = (content: string) => {
// Check cache first
const cached = contentCache.get(content);
if (cached) {
return cached;
}
const segments: Array<
| { type: "text"; value: string; delay: number }
| { type: "code"; value: string; delay: number }
> = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
let cursor = 0;
// Create new regex instance to avoid global state issues
const codeBlockRegex = /```(tsx|jsx|javascript|js)?\n([\s\S]*?)```/g;
while ((match = codeBlockRegex.exec(content)) !== null) {
if (match.index > lastIndex) {
const text = content.slice(lastIndex, match.index);
segments.push({ type: "text", value: text, delay: cursor * 5 });
cursor += text.length;
}
const code = match[2];
segments.push({ type: "code", value: code, delay: cursor * 5 });
cursor += code.length;
lastIndex = match.index + match[0].length;
}
if (lastIndex < content.length) {
const remaining = content.slice(lastIndex);
segments.push({ type: "text", value: remaining, delay: cursor * 5 });
}
// Cache the result
contentCache.set(content, segments);
return segments;
};
const AIChat = memo(function AIChat() {
const containerRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const isProgrammatic = useRef(false);
const timeoutsRef = useRef<number[]>([]);
const shouldAutoScroll = useRef(true);
const isInView = useInView(containerRef, { amount: 0.3, once: true });
const isDarkMode = useThemeDetection();
const [currentIndex, setCurrentIndex] = useState(-1);
const [completedMessages, setCompletedMessages] = useState<Set<number>>(
() => new Set()
);
const [isTyping, setIsTyping] = useState(false);
const [showThinking, setShowThinking] = useState(false);
const [isFinished, setIsFinished] = useState(false);
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0);
const [completedSegments, setCompletedSegments] = useState<Set<number>>(
() => new Set()
);
const scheduleTimeout = useCallback((handler: () => void, delay: number) => {
const id = window.setTimeout(() => {
handler();
timeoutsRef.current = timeoutsRef.current.filter(
(storedId) => storedId !== id
);
}, delay);
timeoutsRef.current.push(id);
return id;
}, []);
const clearScheduled = useCallback(() => {
timeoutsRef.current.forEach((id) => window.clearTimeout(id));
timeoutsRef.current = [];
}, []);
const checkIfAtBottom = useCallback(() => {
const node = scrollRef.current;
if (!node) return true;
const threshold = 50; // pixels from bottom
const isAtBottom =
node.scrollHeight - node.scrollTop - node.clientHeight <= threshold;
return isAtBottom;
}, []);
const scrollToBottom = useCallback(() => {
const node = scrollRef.current;
if (!node) return;
isProgrammatic.current = true;
node.scrollTop = node.scrollHeight;
// Use setTimeout to ensure flag is cleared after scroll event
setTimeout(() => {
isProgrammatic.current = false;
}, 100);
}, []);
useEffect(
() => () => {
clearScheduled();
// Clear cache when component unmounts to prevent memory leaks
contentCache.clear();
},
[clearScheduled]
);
useEffect(() => {
if (isInView && currentIndex === -1 && !isFinished) {
scheduleTimeout(() => {
setCurrentIndex(0);
setIsTyping(true);
shouldAutoScroll.current = true; // Ensure auto-scroll is on when starting
}, 400);
}
}, [currentIndex, isFinished, isInView, scheduleTimeout]);
useEffect(() => {
// Auto-scroll only if shouldAutoScroll is true
if (shouldAutoScroll.current) {
scrollToBottom();
}
}, [currentIndex, isTyping, showThinking, scrollToBottom]);
// Auto-scroll during typing with throttled MutationObserver
useEffect(() => {
const node = scrollRef.current;
if (!node || !isTyping) return;
let timeoutId: NodeJS.Timeout | null = null;
let lastScrollTime = 0;
const observer = new MutationObserver(() => {
// Don't scroll if user has disabled auto-scroll
if (!shouldAutoScroll.current) return;
// Throttle to prevent rapid scrolling
const now = Date.now();
if (now - lastScrollTime < 100) return; // Increased throttle to 100ms
if (timeoutId) return;
timeoutId = setTimeout(() => {
if (shouldAutoScroll.current) {
lastScrollTime = Date.now();
scrollToBottom();
}
timeoutId = null;
}, 100);
});
observer.observe(node, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
observer.disconnect();
if (timeoutId) clearTimeout(timeoutId);
};
}, [isTyping, scrollToBottom]);
// Smart scroll detection: allow user to scroll, but resume auto-scroll when at bottom
useEffect(() => {
const node = scrollRef.current;
if (!node) return;
const handleScroll = () => {
// Ignore programmatic scrolls
if (isProgrammatic.current) return;
// User is manually scrolling - disable auto-scroll immediately
shouldAutoScroll.current = false;
// Check if user scrolled back to bottom
const atBottom = checkIfAtBottom();
// Re-enable auto-scroll only if at bottom
if (atBottom) {
shouldAutoScroll.current = true;
}
};
node.addEventListener("scroll", handleScroll, { passive: true });
return () => {
node.removeEventListener("scroll", handleScroll);
};
}, [checkIfAtBottom]);
const handleSegmentComplete = useCallback(
(segmentIndex: number, totalSegments: number) => {
setCompletedSegments((prev) => {
const next = new Set(prev);
next.add(segmentIndex);
return next;
});
// If this is the last segment, complete the message
if (segmentIndex === totalSegments - 1) {
// Reset segment tracking for next message
setCurrentSegmentIndex(0);
setCompletedSegments(new Set());
if (currentIndex < 0) return;
setCompletedMessages((prev) => {
const next = new Set(prev);
next.add(currentIndex);
return next;
});
setIsTyping(false);
const nextIndex = currentIndex + 1;
const currentMessage = DEMO_MESSAGES[currentIndex];
const nextMessage = DEMO_MESSAGES[nextIndex];
if (!nextMessage) {
setIsFinished(true);
setShowThinking(false);
setCurrentIndex(-1);
return;
}
if (
currentMessage.role === "user" &&
nextMessage.role === "assistant"
) {
setShowThinking(true);
scheduleTimeout(() => {
setShowThinking(false);
setCurrentIndex(nextIndex);
setIsTyping(true);
shouldAutoScroll.current = true; // Re-enable auto-scroll for new message
}, currentMessage.thinkingDelay ?? 1400);
return;
}
scheduleTimeout(() => {
setCurrentIndex(nextIndex);
setIsTyping(true);
shouldAutoScroll.current = true; // Re-enable auto-scroll for new message
}, currentMessage.nextDelay ?? 1400);
} else {
// Move to next segment
setCurrentSegmentIndex(segmentIndex + 1);
}
},
[currentIndex, scheduleTimeout]
);
const handleReplay = useCallback(() => {
clearScheduled();
setCurrentIndex(-1);
setCompletedMessages(new Set());
setIsTyping(false);
setShowThinking(false);
setIsFinished(false);
setCurrentSegmentIndex(0);
setCompletedSegments(new Set());
}, [clearScheduled]);
const visibleMessages = useMemo(() => {
if (currentIndex === -1) {
return Array.from(completedMessages.values()).sort((a, b) => a - b);
}
const baseline = new Set(completedMessages);
baseline.add(currentIndex);
return Array.from(baseline.values()).sort((a, b) => a - b);
}, [completedMessages, currentIndex]);
return (
<div
ref={containerRef}
className="flex h-[480px] max-w-2xl mx-auto w-full flex-col rounded-3xl border border-ring/40 bg-card/40 backdrop-blur"
>
<div className="mb-4 p-6 flex items-center gap-3 border-b border-ring/30 ">
<div className="flex gap-3 items-center justify-center rounded-full bg-primary/15 text-primary">
<MessageSquareText className="h-5 w-5" />
<span className="text-sm font-semibold">AI Chat</span>
</div>
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<span className="h-2 w-2 animate-pulse rounded-full bg-emerald-500" />
Live Demo
</div>
</div>
<div
ref={scrollRef}
className="flex-1 p-4 space-y-4 overflow-y-auto overflow-x-auto scrollbar-visible"
>
{visibleMessages.map((index) => {
const message = DEMO_MESSAGES[index];
const isCurrent = index === currentIndex;
const isComplete = completedMessages.has(index);
const show = isCurrent || isComplete;
if (!show) return null;
const segments = splitContent(message.content);
return (
<motion.div
key={index}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 320, damping: 26 }}
className={cn(
"flex gap-3",
message.role === "user" ? "justify-end" : "justify-start"
)}
>
{message.role === "assistant" && (
<div className="mt-1 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full border border-primary/30 bg-primary/15 text-primary">
<Bot className="h-4 w-4" />
</div>
)}
<div
className={cn(
"relative max-w-[80%] rounded-2xl px-4 py-3 text-sm shadow-sm",
message.role === "user"
? "rounded-br-sm bg-fd-border border border-border/20 text-secondary-foreground"
: "rounded-bl-sm border border-border/20 bg-muted/60 text-muted-foreground"
)}
>
<div className="leading-relaxed">
{isComplete
? renderStaticSegments(message.content, isDarkMode)
: segments.map((segment, segmentIndex) => {
const isCurrentSegment =
segmentIndex === currentSegmentIndex;
const isCompletedSegment =
completedSegments.has(segmentIndex);
const shouldShow =
isCurrent && (isCurrentSegment || isCompletedSegment);
const shouldAnimate =
isCurrent && isCurrentSegment && isTyping;
if (segment.type === "text") {
return (
<span key={`text-${segmentIndex}`}>
{isCompletedSegment ? (
<span className="">{segment.value}</span>
) : shouldShow ? (
<TypingText
text={segment.value}
isVisible={shouldAnimate}
delay={0}
speed={25}
onComplete={() =>
handleSegmentComplete(
segmentIndex,
segments.length
)
}
/>
) : null}
</span>
);
}
return (
<div key={`code-${segmentIndex}`} className="my-3">
{isCompletedSegment ? (
<div className="rounded-lg border border-muted-foreground/20 bg-muted/40 dark:bg-background/40 overflow-x-auto">
<ShikiHighlighter
language="tsx"
theme={
isDarkMode ? "github-dark" : "github-light"
}
className="text-sm font-mono overflow-x-auto bg-transparent min-w-0 max-w-full"
showLanguage={false}
>
{segment.value}
</ShikiHighlighter>
</div>
) : shouldShow ? (
<TypingCodeBlock
code={segment.value}
isVisible={shouldAnimate}
delay={0}
speed={12}
onComplete={() =>
handleSegmentComplete(
segmentIndex,
segments.length
)
}
isDarkMode={isDarkMode}
/>
) : null}
</div>
);
})}
</div>
<div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground/70">
<span>{message.timestamp}</span>
</div>
</div>
{message.role === "user" && (
<div className="mt-1 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full border border-primary/25 bg-fd-border text-primary">
<User className="h-4 w-4" />
</div>
)}
</motion.div>
);
})}
{showThinking && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="flex justify-start gap-3"
>
<div className="mt-1 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full border border-primary/30 bg-primary/15 text-primary">
<Bot className="h-4 w-4" />
</div>
<div className="rounded-2xl rounded-bl-md border border-border/20 bg-muted/60 px-4 py-3 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span>Assistant is thinking</span>
<motion.span
className="flex items-center gap-1"
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ repeat: Infinity, duration: 1.8 }}
>
···
</motion.span>
</div>
</div>
</motion.div>
)}
{isFinished && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="mt-6 flex justify-center"
>
<button
type="button"
onClick={handleReplay}
className="flex items-center gap-2 rounded-lg border border-primary/40 bg-primary/10 px-4 py-2 text-sm font-medium text-primary transition-colors hover:bg-primary/15"
>
<MessageSquareText className="h-4 w-4" />
Replay Demo
</button>
</motion.div>
)}
</div>
</div>
);
});
AIChat.displayName = "AIChat";
export default AIChat;
Usage
import AIChat from "@/components/ui/ai-chat";
export default function Example() {
return (
<div className="max-w-4xl">
<AIChat />
</div>
);
}Customising the transcript
- Update
DEMO_MESSAGESinsideai-chat.tsxto reflect your product story. - Adjust
thinkingDelayandnextDelayvalues for pacing between user and assistant messages. - Tailor the container classes (
bg-card/40,border-ring/40, etc.) to match your brand.
