Created
March 16, 2026 19:53
-
-
Save sebiomoa/e7165808d7c2b961095dc4d134f06e81 to your computer and use it in GitHub Desktop.
WhatsApp-style chat animation component (React + Tailwind CSS)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "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