UI Components Easy
Typing Indicator
A smooth, rhythmic "user is typing" animation for chat interfaces and real-time collaboration tools.
Open in Lab
MCP
css vanilla-js react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
:root {
--typing-dot-color: #8b5cf6;
--typing-bg: rgba(139, 92, 246, 0.1);
--typing-border: rgba(139, 92, 246, 0.25);
--typing-text-color: #94a3b8;
}
.typing-container {
display: flex;
align-items: center;
gap: 0.875rem;
font-family: "Inter", system-ui, sans-serif;
padding: 0.5rem;
}
.typing-bubble {
background: var(--typing-bg);
border: 1px solid var(--typing-border);
padding: 0.75rem 1.125rem;
border-radius: 18px 18px 18px 4px;
display: flex;
gap: 5px;
align-items: center;
backdrop-filter: blur(8px);
box-shadow: 0 0 20px rgba(139, 92, 246, 0.1);
}
.typing-dot {
width: 7px;
height: 7px;
background: var(--typing-dot-color);
border-radius: 50%;
opacity: 0.4;
animation: typing-bounce 1.4s infinite ease-in-out both;
}
.typing-dot:nth-child(1) {
animation-delay: -0.32s;
}
.typing-dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes typing-bounce {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.3;
}
40% {
transform: translateY(-7px);
opacity: 1;
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.5);
}
}
.typing-text {
font-size: 0.875rem;
color: var(--typing-text-color);
font-weight: 500;
letter-spacing: 0.01em;
}// This component is mostly CSS-driven, but you can control visibility via JS
function setTyping(isTyping) {
const container = document.querySelector(".typing-container");
if (container) {
container.style.display = isTyping ? "flex" : "none";
}
}
// Example usage:
// setTyping(true);
// setTimeout(() => setTyping(false), 5000);<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Typing Indicator</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="typing-container">
<div class="typing-bubble">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
<span class="typing-text">AI is typing...</span>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useEffect } from "react";
function TypingDots() {
return (
<div className="flex items-center gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="w-2 h-2 rounded-full bg-[#8b949e]"
style={{ animation: `typing-bounce 1.2s ease-in-out ${i * 0.2}s infinite` }}
/>
))}
</div>
);
}
type Message = {
id: number;
user: string;
avatar: string;
color: string;
text: string;
own?: boolean;
};
const MESSAGES: Message[] = [
{
id: 1,
user: "Sarah",
avatar: "SC",
color: "#bc8cff",
text: "Hey! Did you get a chance to review the PR?",
},
{
id: 2,
user: "You",
avatar: "YO",
color: "#7ee787",
text: "Yes, looks great! Just left a few comments.",
own: true,
},
{ id: 3, user: "Sarah", avatar: "SC", color: "#bc8cff", text: "Perfect, I'll address them now" },
];
export default function TypingIndicatorRC() {
const [typingUser, setTypingUser] = useState<string | null>("Sarah");
const [messages, setMessages] = useState<Message[]>(MESSAGES);
const [input, setInput] = useState("");
useEffect(() => {
const id = setTimeout(() => {
setMessages((m) => [
...m,
{
id: Date.now(),
user: "Sarah",
avatar: "SC",
color: "#bc8cff",
text: "Should be ready in a few minutes!",
},
]);
setTypingUser(null);
}, 3000);
return () => clearTimeout(id);
}, []);
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && input.trim()) {
setMessages((m) => [
...m,
{
id: Date.now(),
user: "You",
avatar: "YO",
color: "#7ee787",
text: input.trim(),
own: true,
},
]);
setInput("");
}
}
return (
<div className="min-h-screen bg-[#0d1117] flex items-center justify-center p-6">
<div className="w-full max-w-sm bg-[#161b22] border border-[#30363d] rounded-2xl overflow-hidden flex flex-col h-[480px]">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-[#30363d]">
<div className="relative">
<div className="w-8 h-8 rounded-full bg-[#bc8cff] flex items-center justify-center text-xs font-bold text-[#0d1117]">
SC
</div>
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-[#7ee787] rounded-full border-2 border-[#161b22]" />
</div>
<div>
<p className="text-[#e6edf3] text-sm font-semibold">Sarah Chen</p>
<p className="text-[#7ee787] text-xs">{typingUser ? "typing…" : "online"}</p>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex items-end gap-2 ${msg.own ? "flex-row-reverse" : ""}`}
>
{!msg.own && (
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-[#0d1117] flex-shrink-0"
style={{ background: msg.color }}
>
{msg.avatar}
</div>
)}
<div
className={`px-3 py-2 rounded-2xl text-sm max-w-[70%] ${
msg.own
? "bg-[#238636] text-white rounded-br-sm"
: "bg-[#21262d] text-[#e6edf3] rounded-bl-sm"
}`}
>
{msg.text}
</div>
</div>
))}
{typingUser && (
<div className="flex items-end gap-2">
<div className="w-6 h-6 rounded-full bg-[#bc8cff] flex items-center justify-center text-[10px] font-bold text-[#0d1117]">
SC
</div>
<div className="bg-[#21262d] px-4 py-3 rounded-2xl rounded-bl-sm">
<TypingDots />
</div>
</div>
)}
</div>
{/* Input */}
<div className="px-3 py-3 border-t border-[#30363d]">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message…"
className="w-full bg-[#0d1117] border border-[#30363d] rounded-xl px-4 py-2 text-[#e6edf3] text-sm placeholder-[#484f58] focus:outline-none focus:border-[#58a6ff]"
/>
</div>
</div>
<style>{`
@keyframes typing-bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
40% { transform: translateY(-5px); opacity: 1; }
}
`}</style>
</div>
);
}<script setup>
import { ref, onMounted, onUnmounted } from "vue";
const typingUser = ref("Sarah");
const messages = ref([
{
id: 1,
user: "Sarah",
avatar: "SC",
color: "#bc8cff",
text: "Hey! Did you get a chance to review the PR?",
},
{
id: 2,
user: "You",
avatar: "YO",
color: "#7ee787",
text: "Yes, looks great! Just left a few comments.",
own: true,
},
{ id: 3, user: "Sarah", avatar: "SC", color: "#bc8cff", text: "Perfect, I'll address them now" },
]);
const input = ref("");
let timer;
onMounted(() => {
timer = setTimeout(() => {
messages.value.push({
id: Date.now(),
user: "Sarah",
avatar: "SC",
color: "#bc8cff",
text: "Should be ready in a few minutes!",
});
typingUser.value = null;
}, 3000);
});
onUnmounted(() => {
clearTimeout(timer);
});
function handleKeyDown(e) {
if (e.key === "Enter" && input.value.trim()) {
messages.value.push({
id: Date.now(),
user: "You",
avatar: "YO",
color: "#7ee787",
text: input.value.trim(),
own: true,
});
input.value = "";
}
}
</script>
<template>
<div class="min-h-screen bg-[#0d1117] flex items-center justify-center p-6">
<div class="w-full max-w-sm bg-[#161b22] border border-[#30363d] rounded-2xl overflow-hidden flex flex-col h-[480px]">
<!-- Header -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-[#30363d]">
<div class="relative">
<div class="w-8 h-8 rounded-full bg-[#bc8cff] flex items-center justify-center text-xs font-bold text-[#0d1117]">SC</div>
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 bg-[#7ee787] rounded-full border-2 border-[#161b22]"></span>
</div>
<div>
<p class="text-[#e6edf3] text-sm font-semibold">Sarah Chen</p>
<p class="text-[#7ee787] text-xs">{{ typingUser ? "typing\u2026" : "online" }}</p>
</div>
</div>
<!-- Messages -->
<div class="flex-1 overflow-y-auto p-4 space-y-3">
<div
v-for="msg in messages"
:key="msg.id"
class="flex items-end gap-2"
:class="{ 'flex-row-reverse': msg.own }"
>
<div
v-if="!msg.own"
class="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-[#0d1117] flex-shrink-0"
:style="{ background: msg.color }"
>
{{ msg.avatar }}
</div>
<div
class="px-3 py-2 rounded-2xl text-sm max-w-[70%]"
:class="msg.own
? 'bg-[#238636] text-white rounded-br-sm'
: 'bg-[#21262d] text-[#e6edf3] rounded-bl-sm'"
>
{{ msg.text }}
</div>
</div>
<div v-if="typingUser" class="flex items-end gap-2">
<div class="w-6 h-6 rounded-full bg-[#bc8cff] flex items-center justify-center text-[10px] font-bold text-[#0d1117]">SC</div>
<div class="bg-[#21262d] px-4 py-3 rounded-2xl rounded-bl-sm">
<div class="flex items-center gap-1">
<span
v-for="i in 3"
:key="i"
class="dot"
:style="{ animationDelay: `${(i - 1) * 0.2}s` }"
></span>
</div>
</div>
</div>
</div>
<!-- Input -->
<div class="px-3 py-3 border-t border-[#30363d]">
<input
v-model="input"
@keydown="handleKeyDown"
placeholder="Message\u2026"
class="w-full bg-[#0d1117] border border-[#30363d] rounded-xl px-4 py-2 text-[#e6edf3] text-sm placeholder-[#484f58] focus:outline-none focus:border-[#58a6ff]"
/>
</div>
</div>
</div>
</template>
<style scoped>
@keyframes typing-bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
40% { transform: translateY(-5px); opacity: 1; }
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
background: #8b949e;
animation: typing-bounce 1.2s ease-in-out infinite;
}
</style><script>
import { onMount, onDestroy } from "svelte";
let typingUser = "Sarah";
let messages = [
{
id: 1,
user: "Sarah",
avatar: "SC",
color: "#bc8cff",
text: "Hey! Did you get a chance to review the PR?",
},
{
id: 2,
user: "You",
avatar: "YO",
color: "#7ee787",
text: "Yes, looks great! Just left a few comments.",
own: true,
},
{ id: 3, user: "Sarah", avatar: "SC", color: "#bc8cff", text: "Perfect, I'll address them now" },
];
let input = "";
let timer;
onMount(() => {
timer = setTimeout(() => {
messages = [
...messages,
{
id: Date.now(),
user: "Sarah",
avatar: "SC",
color: "#bc8cff",
text: "Should be ready in a few minutes!",
},
];
typingUser = null;
}, 3000);
});
onDestroy(() => {
clearTimeout(timer);
});
function handleKeyDown(e) {
if (e.key === "Enter" && input.trim()) {
messages = [
...messages,
{
id: Date.now(),
user: "You",
avatar: "YO",
color: "#7ee787",
text: input.trim(),
own: true,
},
];
input = "";
}
}
</script>
<div class="min-h-screen bg-[#0d1117] flex items-center justify-center p-6">
<div class="w-full max-w-sm bg-[#161b22] border border-[#30363d] rounded-2xl overflow-hidden flex flex-col h-[480px]">
<!-- Header -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-[#30363d]">
<div class="relative">
<div class="w-8 h-8 rounded-full bg-[#bc8cff] flex items-center justify-center text-xs font-bold text-[#0d1117]">SC</div>
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 bg-[#7ee787] rounded-full border-2 border-[#161b22]"></span>
</div>
<div>
<p class="text-[#e6edf3] text-sm font-semibold">Sarah Chen</p>
<p class="text-[#7ee787] text-xs">{typingUser ? "typing\u2026" : "online"}</p>
</div>
</div>
<!-- Messages -->
<div class="flex-1 overflow-y-auto p-4 space-y-3">
{#each messages as msg (msg.id)}
<div class="flex items-end gap-2" class:flex-row-reverse={msg.own}>
{#if !msg.own}
<div
class="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-[#0d1117] flex-shrink-0"
style="background: {msg.color};"
>
{msg.avatar}
</div>
{/if}
<div
class="px-3 py-2 rounded-2xl text-sm max-w-[70%]"
class:bg-[#238636]={msg.own}
class:text-white={msg.own}
class:rounded-br-sm={msg.own}
class:bg-[#21262d]={!msg.own}
class:text-[#e6edf3]={!msg.own}
class:rounded-bl-sm={!msg.own}
>
{msg.text}
</div>
</div>
{/each}
{#if typingUser}
<div class="flex items-end gap-2">
<div class="w-6 h-6 rounded-full bg-[#bc8cff] flex items-center justify-center text-[10px] font-bold text-[#0d1117]">SC</div>
<div class="bg-[#21262d] px-4 py-3 rounded-2xl rounded-bl-sm">
<div class="flex items-center gap-1">
{#each [0, 1, 2] as i}
<span
class="w-2 h-2 rounded-full bg-[#8b949e]"
style="animation: typing-bounce 1.2s ease-in-out {i * 0.2}s infinite;"
></span>
{/each}
</div>
</div>
</div>
{/if}
</div>
<!-- Input -->
<div class="px-3 py-3 border-t border-[#30363d]">
<input
bind:value={input}
on:keydown={handleKeyDown}
placeholder="Message\u2026"
class="w-full bg-[#0d1117] border border-[#30363d] rounded-xl px-4 py-2 text-[#e6edf3] text-sm placeholder-[#484f58] focus:outline-none focus:border-[#58a6ff]"
/>
</div>
</div>
</div>
<style>
@keyframes -global-typing-bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
40% { transform: translateY(-5px); opacity: 1; }
}
</style>Typing Indicator
This component provides crucial visual feedback in messaging environments. It features three dots with a staggered bouncing animation, creating a natural and expected interaction pattern for users waiting for a response.
Features
- Staggered dot animation (Keyframes)
- Minimal CSS footprint
- Flexbox centering
- Easily adjustable timing and colors
- Perfect for chat bubbles or status bars