Skip to content

Instantly share code, notes, and snippets.

@sebiomoa
Created March 16, 2026 19:53
Show Gist options
  • Select an option

  • Save sebiomoa/e7165808d7c2b961095dc4d134f06e81 to your computer and use it in GitHub Desktop.

Select an option

Save sebiomoa/e7165808d7c2b961095dc4d134f06e81 to your computer and use it in GitHub Desktop.
WhatsApp-style chat animation component (React + Tailwind CSS)
"use client";
import { useEffect, useRef, useState } from "react";
type Message = {
id: number;
from: "user" | "bot";
text?: string;
card?: { title: string; category: string; score: number; detail: string };
typing?: true;
};
const SEQUENCE: Message[] = [
{ id: 1, from: "user", text: "Hi" },
{ id: 2, from: "bot", typing: true },
{ id: 3, from: "bot", text: "Hey! Send me your details and I'll find the best matches for you." },
{ id: 4, from: "user", text: "Alex Taylor · Designer · 5 yrs experience · Figma, prototyping, user research..." },
{ id: 5, from: "bot", typing: true },
{ id: 6, from: "bot", text: "Got it. Finding your best matches now..." },
{ id: 7, from: "bot", card: { title: "Senior Product Designer", category: "Technology", score: 96, detail: "$120,000" } },
{ id: 8, from: "bot", card: { title: "UX Design Lead", category: "Finance", score: 91, detail: "$110,000" } },
];
const DELAYS = [600, 500, 1200, 2800, 600, 1100, 1000, 1000];
export default function ChatDemo() {
const [visible, setVisible] = useState<number[]>([]);
const [phase, setPhase] = useState(0);
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when messages change
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}, [visible]);
useEffect(() => {
let cancelled = false;
async function run() {
setVisible([]);
for (let i = 0; i < SEQUENCE.length; i++) {
const msg = SEQUENCE[i];
await sleep(DELAYS[i]);
if (cancelled) return;
if (msg.typing) {
setVisible((v) => [...v, msg.id]);
await sleep(1200);
if (cancelled) return;
setVisible((v) => v.filter((id) => id !== msg.id));
} else {
setVisible((v) => [...v, msg.id]);
}
}
await sleep(3500);
if (!cancelled) setPhase((p) => p + 1);
}
run();
return () => { cancelled = true; };
}, [phase]);
const shown = SEQUENCE.filter((m) => visible.includes(m.id));
return (
<div className="w-full max-w-[440px] mx-auto">
<div className="rounded-xl overflow-hidden flex flex-col shadow-2xl shadow-black/40" style={{ height: 530, background: "#0b141a" }}>
{/* Header */}
<div className="flex items-center gap-3 px-4 py-2.5 shrink-0" style={{ background: "#1f2c34" }}>
<div className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold" style={{ background: "#2a3942", color: "#8696a0" }}>B</div>
<div className="flex-1">
<p className="text-[13px] font-medium leading-tight" style={{ color: "#e9edef" }}>Bot</p>
<p className="text-[11px] leading-tight" style={{ color: "#8696a0" }}>online</p>
</div>
<div className="flex items-center gap-4" style={{ color: "#aebac1" }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M15.05 5A5 5 0 0 1 19 8.95M15.05 1A9 9 0 0 1 23 8.94M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</div>
</div>
{/* Messages */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto px-3 py-3"
style={{ scrollbarWidth: "none", background: "#0b141a" }}
>
<div className="flex flex-col gap-1">
{shown.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.from === "user" ? "justify-end" : "justify-start"}`}
style={{ animation: "msgIn 0.4s cubic-bezier(0.16,1,0.3,1) both" }}
>
{msg.typing ? (
<div className="rounded-lg rounded-bl-sm px-3 py-2.5" style={{ background: "#1f2c34" }}>
<TypingDots />
</div>
) : msg.card ? (
<div className="rounded-lg rounded-bl-sm p-3 max-w-[260px]" style={{ background: "#1f2c34" }}>
<div className="flex items-start justify-between gap-3 mb-1">
<p className="text-xs font-semibold leading-snug" style={{ color: "#e9edef" }}>{msg.card.title}</p>
<span className="shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded" style={{ color: "#00a884", background: "#0b141a" }}>{msg.card.score}%</span>
</div>
<p className="text-[10px]" style={{ color: "#8696a0" }}>{msg.card.category}</p>
<p className="text-[10px] mt-0.5" style={{ color: "#8696a0" }}>{msg.card.detail}</p>
</div>
) : (
<div
className="rounded-lg px-3 py-2 max-w-[260px]"
style={{
background: msg.from === "user" ? "#005c4b" : "#1f2c34",
borderBottomRightRadius: msg.from === "user" ? 2 : undefined,
borderBottomLeftRadius: msg.from !== "user" ? 2 : undefined,
}}
>
<p className="text-[12px] leading-snug" style={{ color: "#e9edef" }}>{msg.text}</p>
</div>
)}
</div>
))}
</div>
</div>
{/* Input bar */}
<div className="shrink-0 px-2 py-1.5 flex items-center gap-1.5" style={{ background: "#0b141a" }}>
<div className="flex-1 h-9 rounded-full flex items-center gap-2 px-3" style={{ background: "#1f2c34" }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8696a0" strokeWidth="1.5" strokeLinecap="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
<span className="text-[13px]" style={{ color: "#8696a0" }}>Message</span>
</div>
<div className="w-9 h-9 rounded-full flex items-center justify-center shrink-0" style={{ background: "#1f2c34" }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="#8696a0"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>
</div>
</div>
</div>
</div>
);
}
function TypingDots() {
return (
<div className="flex items-center gap-1 px-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="w-1.5 h-1.5 rounded-full bg-white/40"
style={{
animation: "typingDot 1.2s ease-in-out infinite",
animationDelay: `${i * 200}ms`,
}}
/>
))}
</div>
);
}
function sleep(ms: number) {
return new Promise<void>((r) => setTimeout(r, ms));
}
/*
Required CSS keyframes (add to your global stylesheet):
@keyframes msgIn {
from {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes typingDot {
0%, 60%, 100% {
opacity: 0.25;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(-3px);
}
}
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment