UI Components Medium
AI Chat Interface
AI chat UI with message bubbles, streaming text simulation, typing indicator, and auto-scroll. No libraries.
Open in Lab
MCP
vanilla-js css react tailwind vue svelte
Targets: TS JS HTML React React Native Vue Svelte
Expo Snack
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f9fafb;
height: 100vh;
display: flex;
justify-content: center;
align-items: stretch;
}
.chat-shell {
display: flex;
flex-direction: column;
width: 100%;
max-width: 720px;
background: #fff;
border-left: 1px solid #e5e7eb;
border-right: 1px solid #e5e7eb;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
}
.chat-model-badge {
display: flex;
align-items: center;
gap: 8px;
}
.model-dot {
width: 8px;
height: 8px;
background: #16a34a;
border-radius: 50%;
animation: pulse-dot 2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.model-name {
font-size: 14px;
font-weight: 700;
color: #111827;
}
.chat-status {
font-size: 12px;
color: #9ca3af;
}
/* Messages */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.msg {
display: flex;
gap: 12px;
align-items: flex-start;
}
.msg-user {
flex-direction: row-reverse;
}
.msg-avatar {
width: 32px;
height: 32px;
border-radius: 10px;
background: #6366f1;
color: #fff;
font-size: 11px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.msg-user .msg-avatar {
background: #111827;
}
.msg-bubble {
max-width: 75%;
background: #f3f4f6;
border-radius: 14px;
padding: 12px 16px;
font-size: 14px;
color: #111827;
line-height: 1.65;
}
.msg-user .msg-bubble {
background: #6366f1;
color: #fff;
}
.msg-bubble p {
margin-bottom: 8px;
}
.msg-bubble p:last-child {
margin-bottom: 0;
}
.msg-bubble code {
background: rgba(0, 0, 0, 0.06);
border-radius: 4px;
padding: 1px 5px;
font-size: 12px;
font-family: Menlo, monospace;
}
.msg-user .msg-bubble code {
background: rgba(255, 255, 255, 0.15);
}
/* Typing indicator */
.typing-bubble {
display: flex;
gap: 5px;
align-items: center;
padding: 14px 18px;
}
.typing-dot {
width: 7px;
height: 7px;
background: #9ca3af;
border-radius: 50%;
animation: typing 1.2s ease-in-out infinite;
}
.typing-dot:nth-child(2) {
animation-delay: 0.2s;
}
.typing-dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%,
80%,
100% {
transform: scale(1);
opacity: 0.5;
}
40% {
transform: scale(1.2);
opacity: 1;
}
}
/* Input area */
.chat-input-area {
padding: 16px 24px 20px;
border-top: 1px solid #e5e7eb;
flex-shrink: 0;
}
.chat-input-wrap {
display: flex;
align-items: flex-end;
gap: 10px;
background: #f9fafb;
border: 1.5px solid #e5e7eb;
border-radius: 14px;
padding: 10px 10px 10px 16px;
transition: border-color 0.15s;
}
.chat-input-wrap:focus-within {
border-color: #6366f1;
}
.chat-input {
flex: 1;
background: none;
border: none;
outline: none;
font-size: 14px;
color: #111;
resize: none;
font-family: inherit;
line-height: 1.5;
max-height: 160px;
overflow-y: auto;
}
.chat-input::placeholder {
color: #9ca3af;
}
.send-btn {
width: 34px;
height: 34px;
background: #6366f1;
border: none;
border-radius: 8px;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: opacity 0.15s;
}
.send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.send-btn:not(:disabled):hover {
opacity: 0.85;
}
.chat-disclaimer {
font-size: 11px;
color: #9ca3af;
text-align: center;
margin-top: 8px;
}const messages = document.getElementById("chatMessages");
const input = document.getElementById("chatInput");
const sendBtn = document.getElementById("sendBtn");
const RESPONSES = [
"Sure! Here's a brief explanation:\n\nThis pattern is commonly used in React to manage state across multiple components without prop drilling. The key benefit is that it keeps your component tree clean.",
"Great question. The difference comes down to execution timing:\n\n- `async/await` pauses execution within the function\n- Promises chain callbacks without blocking\n\nFor most cases, `async/await` is more readable.",
"I'd recommend using a `Map` here instead of a plain object. Maps preserve insertion order and have better performance for frequent additions/deletions.\n\n```js\nconst cache = new Map();\ncache.set('key', value);\n```",
"The short answer is yes — CSS Grid handles this layout pattern better than Flexbox in this case. Use `grid-template-columns: repeat(auto-fill, minmax(240px, 1fr))` for a responsive card grid.",
"There are a few options depending on your stack:\n\n1. **NextAuth.js** — easiest for Next.js apps\n2. **Lucia** — lightweight, framework-agnostic\n3. **Clerk** — hosted, zero-config\n\nFor a quick prototype, I'd start with Clerk.",
];
let responseIndex = 0;
let streaming = false;
function addMessage(role, text) {
const msg = document.createElement("div");
msg.className = `msg msg-${role}`;
const avatar = document.createElement("div");
avatar.className = "msg-avatar";
avatar.textContent = role === "user" ? "You" : "AI";
const bubble = document.createElement("div");
bubble.className = "msg-bubble";
if (text) bubble.innerHTML = `<p>${text.replace(/\n/g, "</p><p>")}</p>`;
msg.appendChild(avatar);
msg.appendChild(bubble);
messages.appendChild(msg);
messages.scrollTop = messages.scrollHeight;
return bubble;
}
function showTyping() {
const msg = document.createElement("div");
msg.className = "msg msg-assistant";
msg.id = "typingMsg";
const avatar = document.createElement("div");
avatar.className = "msg-avatar";
avatar.textContent = "AI";
const bubble = document.createElement("div");
bubble.className = "msg-bubble typing-bubble";
bubble.innerHTML =
'<div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div>';
msg.appendChild(avatar);
msg.appendChild(bubble);
messages.appendChild(msg);
messages.scrollTop = messages.scrollHeight;
}
function removeTyping() {
document.getElementById("typingMsg")?.remove();
}
function streamText(bubble, text, callback) {
// Convert newlines to <br> and **bold** to <strong>
const lines = text.split("\n");
let charIndex = 0;
let allChars = text.split("");
bubble.innerHTML = "";
let p = document.createElement("p");
bubble.appendChild(p);
function tick() {
if (charIndex >= allChars.length) {
// Final format
bubble.innerHTML = text
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
.split("\n")
.map((l) => (l ? `<p>${l}</p>` : "<br>"))
.join("");
callback?.();
return;
}
const char = allChars[charIndex++];
if (char === "\n") {
p = document.createElement("p");
bubble.appendChild(p);
} else {
p.textContent += char;
}
messages.scrollTop = messages.scrollHeight;
setTimeout(tick, 12 + Math.random() * 6);
}
tick();
}
function send() {
const text = input.value.trim();
if (!text || streaming) return;
addMessage("user", text);
input.value = "";
input.style.height = "auto";
sendBtn.disabled = true;
streaming = true;
showTyping();
setTimeout(
() => {
removeTyping();
const response = RESPONSES[responseIndex % RESPONSES.length];
responseIndex++;
const bubble = addMessage("assistant", "");
streamText(bubble, response, () => {
streaming = false;
sendBtn.disabled = false;
});
},
900 + Math.random() * 400
);
}
sendBtn.addEventListener("click", send);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
});
// Auto-resize textarea
input.addEventListener("input", () => {
input.style.height = "auto";
input.style.height = Math.min(input.scrollHeight, 160) + "px";
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Chat Interface</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="chat-shell">
<header class="chat-header">
<div class="chat-model-badge">
<span class="model-dot"></span>
<span class="model-name">Claude Sonnet</span>
</div>
<span class="chat-status">Ready</span>
</header>
<div class="chat-messages" id="chatMessages">
<div class="msg msg-assistant">
<div class="msg-avatar">AI</div>
<div class="msg-bubble">
<p>Hi! I'm your AI assistant. Ask me anything — I can help with code, writing, analysis, and more.</p>
</div>
</div>
</div>
<div class="chat-input-area">
<div class="chat-input-wrap">
<textarea class="chat-input" id="chatInput" placeholder="Message Claude…" rows="1"></textarea>
<button class="send-btn" id="sendBtn" aria-label="Send">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
<p class="chat-disclaimer">AI may make mistakes. Verify important information.</p>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useRef, useEffect, useCallback } from "react";
interface Message {
id: number;
role: "user" | "assistant";
content: string;
streaming?: boolean;
}
const RESPONSES: string[] = [
"That's a great question! Let me think through this carefully.\n\nThe key insight here is that you need to consider both the time and space complexity. For most practical cases, a hash map approach gives you O(1) lookups which is exactly what you want.",
"I understand what you're looking for. Here's my take:\n\nYou can approach this problem from two angles — either top-down with memoization, or bottom-up with tabulation. Both work, but tabulation often has better cache performance.",
"Sure! The short answer is: **yes, that's possible**.\n\nThe longer answer involves understanding how the event loop works. JavaScript is single-threaded, so asynchronous operations are handled through the microtask queue and the callback queue.",
"Great point! Let me break that down:\n\n1. First, initialize your state properly\n2. Then handle the edge cases\n3. Finally, make sure you clean up any side effects\n\nThis pattern works reliably in production environments.",
"Interesting question. The tradeoff here is between **readability** and **performance**. For small datasets, readability wins. For large datasets — say 10k+ items — you'll want to optimize.",
];
let msgId = 0;
export default function ChatInterfaceRC() {
const [messages, setMessages] = useState<Message[]>([
{
id: ++msgId,
role: "assistant",
content: "Hello! I'm your AI assistant. Ask me anything — I'm here to help.",
},
]);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const responseIdx = useRef(0);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const stream = useCallback((id: number, text: string) => {
let i = 0;
const tick = setInterval(() => {
i++;
setMessages((prev) =>
prev.map((m) =>
m.id === id ? { ...m, content: text.slice(0, i), streaming: i < text.length } : m
)
);
if (i >= text.length) {
clearInterval(tick);
setIsStreaming(false);
}
}, 12);
}, []);
const send = useCallback(() => {
const text = input.trim();
if (!text || isStreaming) return;
const userId = ++msgId;
const assistantId = ++msgId;
const response = RESPONSES[responseIdx.current % RESPONSES.length];
responseIdx.current++;
setMessages((prev) => [
...prev,
{ id: userId, role: "user", content: text },
{ id: assistantId, role: "assistant", content: "", streaming: true },
]);
setInput("");
setIsStreaming(true);
setTimeout(() => stream(assistantId, response), 400);
}, [input, isStreaming, stream]);
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
return (
<div className="min-h-screen bg-[#0d1117] p-4 flex justify-center items-center">
<div className="w-full max-w-[680px] h-[600px] flex flex-col bg-[#161b22] border border-[#30363d] rounded-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-5 py-3.5 bg-[#21262d] border-b border-[#30363d] flex-shrink-0">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-[#58a6ff] to-[#bc8cff] flex items-center justify-center flex-shrink-0">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="2.5"
>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" />
<path d="M9 9h.01M15 9h.01M9 15s1.5 2 3 2 3-2 3-2" />
</svg>
</div>
<div>
<p className="text-[13px] font-bold text-[#e6edf3]">AI Assistant</p>
<p className="text-[11px] text-green-400">{isStreaming ? "Typing…" : "Online"}</p>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex gap-3 ${msg.role === "user" ? "flex-row-reverse" : ""}`}
>
{/* Avatar */}
<div
className={`w-7 h-7 rounded-full flex-shrink-0 flex items-center justify-center text-[11px] font-bold ${
msg.role === "assistant"
? "bg-gradient-to-br from-[#58a6ff] to-[#bc8cff] text-white"
: "bg-[#30363d] text-[#8b949e]"
}`}
>
{msg.role === "assistant" ? "AI" : "U"}
</div>
{/* Bubble */}
<div
className={`max-w-[80%] px-4 py-2.5 rounded-2xl text-[13px] leading-relaxed whitespace-pre-wrap ${
msg.role === "user"
? "bg-[#58a6ff] text-white rounded-tr-sm"
: "bg-[#21262d] text-[#e6edf3] border border-[#30363d] rounded-tl-sm"
}`}
>
{msg.content}
{msg.streaming && (
<span className="inline-block w-2 h-4 bg-[#58a6ff] ml-0.5 align-middle animate-pulse rounded-sm" />
)}
</div>
</div>
))}
{/* Typing indicator (before response starts) */}
{isStreaming && messages[messages.length - 1]?.content === "" && (
<div className="flex gap-3">
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-[#58a6ff] to-[#bc8cff] flex-shrink-0 flex items-center justify-center text-[11px] font-bold text-white">
AI
</div>
<div className="bg-[#21262d] border border-[#30363d] rounded-2xl rounded-tl-sm px-4 py-3 flex items-center gap-1.5">
{[0, 1, 2].map((i) => (
<span
key={i}
className="w-1.5 h-1.5 bg-[#8b949e] rounded-full animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</div>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div className="flex-shrink-0 px-4 py-3 border-t border-[#30363d] bg-[#161b22]">
<div className="flex items-end gap-2 bg-[#21262d] border border-[#30363d] rounded-xl px-3 py-2 focus-within:border-[#58a6ff] transition-colors">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKey}
placeholder="Message AI…"
rows={1}
className="flex-1 bg-transparent text-[13px] text-[#e6edf3] placeholder-[#484f58] resize-none outline-none leading-relaxed max-h-32"
style={{ overflowY: input.split("\n").length > 4 ? "auto" : "hidden" }}
disabled={isStreaming}
/>
<button
onClick={send}
disabled={!input.trim() || isStreaming}
className="w-8 h-8 rounded-lg bg-[#58a6ff] flex items-center justify-center flex-shrink-0 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-[#79b8ff] transition-colors"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
</button>
</div>
<p className="text-[10px] text-[#484f58] mt-1.5 text-center">
Enter to send · Shift+Enter for new line
</p>
</div>
</div>
</div>
);
}import React, { useState, useRef, useEffect } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
FlatList,
Animated,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from "react-native";
interface Message {
id: string;
role: "user" | "assistant";
text: string;
}
function TypingIndicator() {
const dots = [
useRef(new Animated.Value(0)).current,
useRef(new Animated.Value(0)).current,
useRef(new Animated.Value(0)).current,
];
useEffect(() => {
const animations = dots.map((dot, i) =>
Animated.loop(
Animated.sequence([
Animated.delay(i * 200),
Animated.timing(dot, {
toValue: -6,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(dot, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
])
)
);
Animated.parallel(animations).start();
return () => animations.forEach((a) => a.stop());
}, []);
return (
<View style={styles.typingRow}>
<View style={styles.assistantBubble}>
<View style={styles.dotsContainer}>
{dots.map((dot, i) => (
<Animated.View key={i} style={[styles.dot, { transform: [{ translateY: dot }] }]} />
))}
</View>
</View>
</View>
);
}
const initialMessages: Message[] = [
{ id: "1", role: "user", text: "Hey, can you help me with React Native?" },
{
id: "2",
role: "assistant",
text: "Of course! I'd be happy to help you with React Native. What would you like to know?",
},
{ id: "3", role: "user", text: "How do I handle animations?" },
{
id: "4",
role: "assistant",
text: "React Native provides the Animated API for performant animations. You can use Animated.timing, Animated.spring, and Animated.decay for different effects. For complex sequences, use Animated.parallel or Animated.sequence.",
},
];
export default function App() {
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [input, setInput] = useState("");
const [typing, setTyping] = useState(false);
const flatListRef = useRef<FlatList>(null);
let nextId = useRef(initialMessages.length + 1);
const sendMessage = () => {
const text = input.trim();
if (!text) return;
const userMsg: Message = {
id: String(nextId.current++),
role: "user",
text,
};
setMessages((prev) => [...prev, userMsg]);
setInput("");
setTyping(true);
setTimeout(() => {
const aiMsg: Message = {
id: String(nextId.current++),
role: "assistant",
text: "That's a great question! Let me think about it and provide you with a helpful answer.",
};
setTyping(false);
setMessages((prev) => [...prev, aiMsg]);
}, 1000);
};
const renderMessage = ({ item }: { item: Message }) => {
const isUser = item.role === "user";
return (
<View style={[styles.messageRow, isUser && styles.messageRowUser]}>
<View style={[isUser ? styles.userBubble : styles.assistantBubble]}>
<Text style={[styles.messageText, isUser && styles.userText]}>{item.text}</Text>
</View>
</View>
);
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View style={styles.header}>
<Text style={styles.headerTitle}>AI Assistant</Text>
<View style={styles.onlineDot} />
</View>
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.messageList}
onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
ListFooterComponent={typing ? <TypingIndicator /> : null}
/>
<View style={styles.inputBar}>
<TextInput
style={styles.textInput}
value={input}
onChangeText={setInput}
placeholder="Type a message..."
placeholderTextColor="#64748b"
onSubmitEditing={sendMessage}
returnKeyType="send"
/>
<TouchableOpacity
style={[styles.sendButton, !input.trim() && styles.sendButtonDisabled]}
onPress={sendMessage}
disabled={!input.trim()}
activeOpacity={0.7}
>
<Text style={styles.sendIcon}>↑</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f172a",
},
header: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 20,
paddingTop: 56,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: "#1e293b",
},
headerTitle: {
color: "#f8fafc",
fontSize: 18,
fontWeight: "700",
},
onlineDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: "#22c55e",
marginLeft: 8,
},
messageList: {
padding: 16,
paddingBottom: 8,
},
messageRow: {
flexDirection: "row",
marginBottom: 12,
justifyContent: "flex-start",
},
messageRowUser: {
justifyContent: "flex-end",
},
assistantBubble: {
backgroundColor: "#1e293b",
borderRadius: 16,
borderTopLeftRadius: 4,
paddingHorizontal: 14,
paddingVertical: 10,
maxWidth: "78%",
},
userBubble: {
backgroundColor: "#6366f1",
borderRadius: 16,
borderTopRightRadius: 4,
paddingHorizontal: 14,
paddingVertical: 10,
maxWidth: "78%",
},
messageText: {
color: "#e2e8f0",
fontSize: 15,
lineHeight: 21,
},
userText: {
color: "#fff",
},
typingRow: {
flexDirection: "row",
marginBottom: 12,
},
dotsContainer: {
flexDirection: "row",
gap: 4,
paddingVertical: 4,
},
dot: {
width: 7,
height: 7,
borderRadius: 3.5,
backgroundColor: "#64748b",
},
inputBar: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 10,
borderTopWidth: 1,
borderTopColor: "#1e293b",
paddingBottom: 34,
},
textInput: {
flex: 1,
backgroundColor: "#1e293b",
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 10,
color: "#f8fafc",
fontSize: 15,
marginRight: 8,
},
sendButton: {
width: 38,
height: 38,
borderRadius: 19,
backgroundColor: "#6366f1",
alignItems: "center",
justifyContent: "center",
},
sendButtonDisabled: {
opacity: 0.4,
},
sendIcon: {
color: "#fff",
fontSize: 18,
fontWeight: "700",
},
});<script setup>
import { ref, nextTick, watch } from "vue";
const RESPONSES = [
"That's a great question! Let me think through this carefully.\n\nThe key insight here is that you need to consider both the time and space complexity. For most practical cases, a hash map approach gives you O(1) lookups which is exactly what you want.",
"I understand what you're looking for. Here's my take:\n\nYou can approach this problem from two angles — either top-down with memoization, or bottom-up with tabulation. Both work, but tabulation often has better cache performance.",
"Sure! The short answer is: **yes, that's possible**.\n\nThe longer answer involves understanding how the event loop works. JavaScript is single-threaded, so asynchronous operations are handled through the microtask queue and the callback queue.",
"Great point! Let me break that down:\n\n1. First, initialize your state properly\n2. Then handle the edge cases\n3. Finally, make sure you clean up any side effects\n\nThis pattern works reliably in production environments.",
"Interesting question. The tradeoff here is between **readability** and **performance**. For small datasets, readability wins. For large datasets — say 10k+ items — you'll want to optimize.",
];
let msgId = 0;
const messages = ref([
{
id: ++msgId,
role: "assistant",
content: "Hello! I'm your AI assistant. Ask me anything — I'm here to help.",
streaming: false,
},
]);
const input = ref("");
const isStreaming = ref(false);
const bottomRef = ref(null);
let responseIdx = 0;
watch(
messages,
() => {
nextTick(() => {
bottomRef.value?.scrollIntoView({ behavior: "smooth" });
});
},
{ deep: true }
);
function stream(id, text) {
let i = 0;
const tick = setInterval(() => {
i++;
messages.value = messages.value.map((m) =>
m.id === id ? { ...m, content: text.slice(0, i), streaming: i < text.length } : m
);
if (i >= text.length) {
clearInterval(tick);
isStreaming.value = false;
}
}, 12);
}
function send() {
const text = input.value.trim();
if (!text || isStreaming.value) return;
const userId = ++msgId;
const assistantId = ++msgId;
const response = RESPONSES[responseIdx % RESPONSES.length];
responseIdx++;
messages.value = [
...messages.value,
{ id: userId, role: "user", content: text, streaming: false },
{ id: assistantId, role: "assistant", content: "", streaming: true },
];
input.value = "";
isStreaming.value = true;
setTimeout(() => stream(assistantId, response), 400);
}
function handleKey(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}
</script>
<template>
<div class="min-h-screen bg-[#0d1117] p-4 flex justify-center items-center">
<div class="w-full max-w-[680px] h-[600px] flex flex-col bg-[#161b22] border border-[#30363d] rounded-2xl overflow-hidden">
<!-- Header -->
<div class="flex items-center gap-3 px-5 py-3.5 bg-[#21262d] border-b border-[#30363d] flex-shrink-0">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-[#58a6ff] to-[#bc8cff] flex items-center justify-center flex-shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/>
<path d="M9 9h.01M15 9h.01M9 15s1.5 2 3 2 3-2 3-2"/>
</svg>
</div>
<div>
<p class="text-[13px] font-bold text-[#e6edf3]">AI Assistant</p>
<p class="text-[11px] text-green-400">
{{ isStreaming ? 'Typing...' : 'Online' }}
</p>
</div>
</div>
<!-- Messages -->
<div class="flex-1 overflow-y-auto px-5 py-4 space-y-4">
<div
v-for="msg in messages"
:key="msg.id"
class="flex gap-3"
:class="{ 'flex-row-reverse': msg.role === 'user' }"
>
<!-- Avatar -->
<div
class="w-7 h-7 rounded-full flex-shrink-0 flex items-center justify-center text-[11px] font-bold"
:class="msg.role === 'assistant'
? 'bg-gradient-to-br from-[#58a6ff] to-[#bc8cff] text-white'
: 'bg-[#30363d] text-[#8b949e]'"
>
{{ msg.role === 'assistant' ? 'AI' : 'U' }}
</div>
<!-- Bubble -->
<div
class="max-w-[80%] px-4 py-2.5 rounded-2xl text-[13px] leading-relaxed whitespace-pre-wrap"
:class="msg.role === 'user'
? 'bg-[#58a6ff] text-white rounded-tr-sm'
: 'bg-[#21262d] text-[#e6edf3] border border-[#30363d] rounded-tl-sm'"
>
{{ msg.content }}
<span
v-if="msg.streaming"
class="inline-block w-2 h-4 bg-[#58a6ff] ml-0.5 align-middle animate-pulse rounded-sm"
></span>
</div>
</div>
<!-- Typing indicator -->
<div v-if="isStreaming && messages[messages.length - 1]?.content === ''" class="flex gap-3">
<div class="w-7 h-7 rounded-full bg-gradient-to-br from-[#58a6ff] to-[#bc8cff] flex-shrink-0 flex items-center justify-center text-[11px] font-bold text-white">
AI
</div>
<div class="bg-[#21262d] border border-[#30363d] rounded-2xl rounded-tl-sm px-4 py-3 flex items-center gap-1.5">
<span
v-for="i in 3"
:key="i"
class="w-1.5 h-1.5 bg-[#8b949e] rounded-full animate-bounce"
:style="{ animationDelay: `${(i - 1) * 150}ms` }"
></span>
</div>
</div>
<div ref="bottomRef"></div>
</div>
<!-- Input -->
<div class="flex-shrink-0 px-4 py-3 border-t border-[#30363d] bg-[#161b22]">
<div class="flex items-end gap-2 bg-[#21262d] border border-[#30363d] rounded-xl px-3 py-2 focus-within:border-[#58a6ff] transition-colors">
<textarea
v-model="input"
@keydown="handleKey"
placeholder="Message AI..."
rows="1"
class="flex-1 bg-transparent text-[13px] text-[#e6edf3] placeholder-[#484f58] resize-none outline-none leading-relaxed max-h-32"
:style="{ overflowY: input.split('\n').length > 4 ? 'auto' : 'hidden' }"
:disabled="isStreaming"
></textarea>
<button
@click="send"
:disabled="!input.trim() || isStreaming"
class="w-8 h-8 rounded-lg bg-[#58a6ff] flex items-center justify-center flex-shrink-0 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-[#79b8ff] transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
<p class="text-[10px] text-[#484f58] mt-1.5 text-center">
Enter to send · Shift+Enter for new line
</p>
</div>
</div>
</div>
</template><script>
import { onMount, afterUpdate } from "svelte";
const RESPONSES = [
"That's a great question! Let me think through this carefully.\n\nThe key insight here is that you need to consider both the time and space complexity. For most practical cases, a hash map approach gives you O(1) lookups which is exactly what you want.",
"I understand what you're looking for. Here's my take:\n\nYou can approach this problem from two angles — either top-down with memoization, or bottom-up with tabulation. Both work, but tabulation often has better cache performance.",
"Sure! The short answer is: **yes, that's possible**.\n\nThe longer answer involves understanding how the event loop works. JavaScript is single-threaded, so asynchronous operations are handled through the microtask queue and the callback queue.",
"Great point! Let me break that down:\n\n1. First, initialize your state properly\n2. Then handle the edge cases\n3. Finally, make sure you clean up any side effects\n\nThis pattern works reliably in production environments.",
"Interesting question. The tradeoff here is between **readability** and **performance**. For small datasets, readability wins. For large datasets — say 10k+ items — you'll want to optimize.",
];
let msgId = 0;
let messages = [
{
id: ++msgId,
role: "assistant",
content: "Hello! I'm your AI assistant. Ask me anything — I'm here to help.",
streaming: false,
},
];
let input = "";
let isStreaming = false;
let bottomEl;
let responseIdx = 0;
afterUpdate(() => {
if (bottomEl) bottomEl.scrollIntoView({ behavior: "smooth" });
});
function stream(id, text) {
let i = 0;
const tick = setInterval(() => {
i++;
messages = messages.map((m) =>
m.id === id ? { ...m, content: text.slice(0, i), streaming: i < text.length } : m
);
if (i >= text.length) {
clearInterval(tick);
isStreaming = false;
}
}, 12);
}
function send() {
const text = input.trim();
if (!text || isStreaming) return;
const userId = ++msgId;
const assistantId = ++msgId;
const response = RESPONSES[responseIdx % RESPONSES.length];
responseIdx++;
messages = [
...messages,
{ id: userId, role: "user", content: text, streaming: false },
{ id: assistantId, role: "assistant", content: "", streaming: true },
];
input = "";
isStreaming = true;
setTimeout(() => stream(assistantId, response), 400);
}
function handleKey(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}
</script>
<div class="min-h-screen bg-[#0d1117] p-4 flex justify-center items-center">
<div class="w-full max-w-[680px] h-[600px] flex flex-col bg-[#161b22] border border-[#30363d] rounded-2xl overflow-hidden">
<!-- Header -->
<div class="flex items-center gap-3 px-5 py-3.5 bg-[#21262d] border-b border-[#30363d] flex-shrink-0">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-[#58a6ff] to-[#bc8cff] flex items-center justify-center flex-shrink-0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/>
<path d="M9 9h.01M15 9h.01M9 15s1.5 2 3 2 3-2 3-2"/>
</svg>
</div>
<div>
<p class="text-[13px] font-bold text-[#e6edf3]">AI Assistant</p>
<p class="text-[11px] text-green-400">
{isStreaming ? 'Typing...' : 'Online'}
</p>
</div>
</div>
<!-- Messages -->
<div class="flex-1 overflow-y-auto px-5 py-4 space-y-4">
{#each messages as msg (msg.id)}
<div class="flex gap-3" class:flex-row-reverse={msg.role === 'user'}>
<!-- Avatar -->
<div
class="w-7 h-7 rounded-full flex-shrink-0 flex items-center justify-center text-[11px] font-bold"
class:bg-gradient-to-br={msg.role === 'assistant'}
class:from-[#58a6ff]={msg.role === 'assistant'}
class:to-[#bc8cff]={msg.role === 'assistant'}
class:text-white={msg.role === 'assistant'}
class:bg-[#30363d]={msg.role === 'user'}
class:text-[#8b949e]={msg.role === 'user'}
>
{msg.role === 'assistant' ? 'AI' : 'U'}
</div>
<!-- Bubble -->
<div
class="max-w-[80%] px-4 py-2.5 rounded-2xl text-[13px] leading-relaxed whitespace-pre-wrap"
class:bg-[#58a6ff]={msg.role === 'user'}
class:text-white={msg.role === 'user'}
class:rounded-tr-sm={msg.role === 'user'}
class:bg-[#21262d]={msg.role === 'assistant'}
class:text-[#e6edf3]={msg.role === 'assistant'}
class:border={msg.role === 'assistant'}
class:border-[#30363d]={msg.role === 'assistant'}
class:rounded-tl-sm={msg.role === 'assistant'}
>
{msg.content}
{#if msg.streaming}
<span class="inline-block w-2 h-4 bg-[#58a6ff] ml-0.5 align-middle animate-pulse rounded-sm"></span>
{/if}
</div>
</div>
{/each}
<!-- Typing indicator -->
{#if isStreaming && messages[messages.length - 1]?.content === ''}
<div class="flex gap-3">
<div class="w-7 h-7 rounded-full bg-gradient-to-br from-[#58a6ff] to-[#bc8cff] flex-shrink-0 flex items-center justify-center text-[11px] font-bold text-white">
AI
</div>
<div class="bg-[#21262d] border border-[#30363d] rounded-2xl rounded-tl-sm px-4 py-3 flex items-center gap-1.5">
{#each [0, 1, 2] as i}
<span
class="w-1.5 h-1.5 bg-[#8b949e] rounded-full animate-bounce"
style="animation-delay: {i * 150}ms"
></span>
{/each}
</div>
</div>
{/if}
<div bind:this={bottomEl}></div>
</div>
<!-- Input -->
<div class="flex-shrink-0 px-4 py-3 border-t border-[#30363d] bg-[#161b22]">
<div class="flex items-end gap-2 bg-[#21262d] border border-[#30363d] rounded-xl px-3 py-2 focus-within:border-[#58a6ff] transition-colors">
<textarea
bind:value={input}
on:keydown={handleKey}
placeholder="Message AI..."
rows="1"
class="flex-1 bg-transparent text-[13px] text-[#e6edf3] placeholder-[#484f58] resize-none outline-none leading-relaxed max-h-32"
style="overflow-y: {input.split('\n').length > 4 ? 'auto' : 'hidden'}"
disabled={isStreaming}
></textarea>
<button
on:click={send}
disabled={!input.trim() || isStreaming}
class="w-8 h-8 rounded-lg bg-[#58a6ff] flex items-center justify-center flex-shrink-0 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-[#79b8ff] transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
<p class="text-[10px] text-[#484f58] mt-1.5 text-center">
Enter to send · Shift+Enter for new line
</p>
</div>
</div>
</div>AI chat interface with user and assistant message bubbles, simulated streaming text output character-by-character, a typing indicator, auto-scroll to latest message, and a textarea input with send button.