A light chat window in which the assistant answers the way a live stream does: three bouncing dots first, then the reply appearing one word at a time, each word fading up out of a blur behind a pulsing caret. A scripted conversation replays on a loop until a visitor sends a message or taps a suggestion. On phones the copy sits above the window and the thread scrolls inside its own frame.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
import { ArrowUp, Copy, RotateCcw, ThumbsUp } from "lucide-react";
// The scripted conversation. Every reply is canned demo text; swap in your own stream.
const script = [
{
user: "Plan me a slow weekend in Lisbon. I like bakeries, bookshops and a good view.",
assistant:
"Here is a slow weekend that keeps you on foot.\nSaturday starts in Alfama with a warm custard tart from a corner bakery, then a climb to the Graca viewpoint before the crowds arrive. Spend the afternoon in the old bookshops of Chiado and watch the sunset from the river steps.\nSunday is for Belem: the gardens, the tiled cloister and a long lunch by the water.",
},
{
user: "Add a backup in case it rains.",
assistant:
"If it rains, swap the viewpoint for the tile museum, which is quiet before noon.\nThe covered market in Campo de Ourique has six bakeries under one roof, and the tram back passes two of the bookshops on your list.",
},
];
const fallbackReply = "Good question. I would keep the mornings for walking and the afternoons indoors, then book one long dinner near the river. Tell me your dates and I will check what is open.";
const suggestions = ["Make it three days", "Add a budget", "Vegetarian places only"];
type Message = { id: number; role: "user" | "assistant"; text: string; shown: number };
const wordsOf = (text: string): string[] => text.match(/\S+\s*/g) ?? [];
export default function StreamingAiChatThreadWithTypingIndicator() {
const reduce = useReducedMotion();
const rootRef = useRef<HTMLElement>(null);
const threadRef = useRef<HTMLDivElement>(null);
const inView = useInView(rootRef, { amount: 0.25 });
const [messages, setMessages] = useState<Message[]>([{ id: 1, role: "user", text: script[0].user, shown: 0 }]);
const [demo, setDemo] = useState(true);
const [draft, setDraft] = useState("");
const last = messages[messages.length - 1];
const lastWords = wordsOf(last.text).length;
const thinking = last.role === "user";
const streaming = last.role === "assistant" && last.shown < lastWords;
// One timer drives everything: think, stream a word, then (in demo mode) ask the next question.
useEffect(() => {
if (!inView) return;
let timer: ReturnType<typeof setTimeout> | undefined;
if (thinking) {
const turn = script.find((s) => s.user === last.text);
const text = turn ? turn.assistant : fallbackReply;
const full = wordsOf(text).length;
timer = setTimeout(() => setMessages((list) => [...list, { id: last.id + 1, role: "assistant", text, shown: reduce ? full : 1 }]), reduce ? 0 : 800);
} else if (streaming) {
timer = setTimeout(() => setMessages((list) => list.map((m) => (m.id === last.id ? { ...m, shown: m.shown + 1 } : m))), 34);
} else if (demo && !reduce) {
const asked = messages.filter((m) => m.role === "user").length;
timer = setTimeout(
() => setMessages((list) => (asked < script.length ? [...list, { id: last.id + 1, role: "user", text: script[asked].user, shown: 0 }] : [{ id: last.id + 1, role: "user", text: script[0].user, shown: 0 }])),
asked < script.length ? 2200 : 4200,
);
}
return () => clearTimeout(timer);
}, [messages, inView, demo, reduce, thinking, streaming, last.id, last.text]);
// Keep the newest line in view while words arrive.
useEffect(() => {
const el = threadRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages]);
const ask = (text: string) => {
const clean = text.trim();
if (!clean || thinking || streaming) return;
setDemo(false);
setDraft("");
setMessages((list) => [...list, { id: last.id + 1, role: "user", text: clean, shown: 0 }]);
};
const replay = () => {
setDemo(true);
setMessages([{ id: last.id + 1, role: "user", text: script[0].user, shown: 0 }]);
};
return (
<section ref={rootRef} className="bg-stone-100 px-4 py-14 text-stone-900 sm:px-6 sm:py-20">
<style>{`
@keyframes streaming-ai-chat-thread-dot { 0%, 60%, 100% { transform: translateY(0); opacity: .35; } 30% { transform: translateY(-5px); opacity: 1; } }
@keyframes streaming-ai-chat-thread-word { from { opacity: 0; transform: translateY(5px); filter: blur(5px); } to { opacity: 1; transform: none; filter: blur(0); } }
@keyframes streaming-ai-chat-thread-caret { 50% { opacity: .15; } }
.streaming-ai-chat-thread-dot { animation: streaming-ai-chat-thread-dot 1.1s ease-in-out infinite; }
.streaming-ai-chat-thread-word { animation: streaming-ai-chat-thread-word .45s cubic-bezier(.22,1,.36,1) both; }
.streaming-ai-chat-thread-caret { animation: streaming-ai-chat-thread-caret .9s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.streaming-ai-chat-thread-dot, .streaming-ai-chat-thread-word, .streaming-ai-chat-thread-caret { animation: none; }
}
`}</style>
<div className="mx-auto grid max-w-5xl items-center gap-10 lg:grid-cols-[1fr_1.25fr] lg:gap-14">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-emerald-700">Wayfare assistant</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl">Answers that arrive the way people talk.</h2>
<p className="mt-4 max-w-md text-stone-600">Replies stream in word by word, so travellers start reading in under a second instead of watching a spinner.</p>
<button type="button" onClick={replay} className="mt-6 inline-flex h-11 items-center gap-2 rounded-full bg-stone-900 px-5 text-sm font-semibold text-white transition hover:bg-stone-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-100">
<RotateCcw aria-hidden="true" className="h-4 w-4" />
Replay the conversation
</button>
</div>
<motion.div initial={reduce ? false : { opacity: 0, y: 28 }} animate={{ opacity: 1, y: 0 }} transition={{ type: "spring", stiffness: 260, damping: 26 }} className="flex h-[560px] flex-col overflow-hidden rounded-[28px] border border-stone-200 bg-white shadow-2xl shadow-stone-900/10">
<div className="flex items-center gap-3 border-b border-stone-100 px-5 py-3.5">
<span className="relative grid h-9 w-9 place-items-center rounded-full bg-gradient-to-br from-emerald-400 to-teal-600 text-sm font-semibold text-white">
W<span className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white bg-emerald-500" />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold">Wayfare</p>
<p className="text-xs text-stone-500" aria-live="polite">
{thinking ? "Thinking..." : streaming ? "Writing..." : "Trip planning assistant"}
</p>
</div>
</div>
<div ref={threadRef} className="flex-1 space-y-5 overflow-y-auto px-5 py-5" role="log" aria-label="Conversation">
<AnimatePresence initial={false} mode="popLayout">
{messages.map((message) =>
message.role === "user" ? (
<motion.div key={message.id} initial={reduce ? false : { opacity: 0, y: 16, scale: 0.94 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0 }} transition={{ type: "spring", stiffness: 420, damping: 28 }} className="flex origin-bottom-right justify-end">
<p className="max-w-[85%] rounded-3xl rounded-br-lg bg-stone-900 px-4 py-2.5 text-[15px] leading-relaxed text-white">{message.text}</p>
</motion.div>
) : (
<motion.div key={message.id} initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex gap-3">
<span aria-hidden="true" className="mt-0.5 grid h-7 w-7 flex-none place-items-center rounded-full bg-gradient-to-br from-emerald-400 to-teal-600 text-xs font-semibold text-white">W</span>
<div className="min-w-0 flex-1">
<p className="sr-only">{message.shown >= wordsOf(message.text).length ? message.text : ""}</p>
<div aria-hidden="true" className="space-y-3 text-[15px] leading-relaxed text-stone-800">
{wordsOf(message.text)
.slice(0, message.shown)
.join("")
.split("\n")
.map((paragraph, p, all) => (
<p key={p}>
{wordsOf(paragraph).map((word, w) => (
<span key={w} className="streaming-ai-chat-thread-word inline-block whitespace-pre">
{word}
</span>
))}
{p === all.length - 1 && message.shown < wordsOf(message.text).length && <span className="streaming-ai-chat-thread-caret -mr-3 ml-0.5 inline-block h-4 w-2 translate-y-0.5 rounded-sm bg-emerald-500" />}
</p>
))}
</div>
{message.shown >= wordsOf(message.text).length && (
<motion.div initial={reduce ? false : { opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }} className="mt-2 flex gap-1 text-stone-500">
{[
{ label: "Copy reply", icon: Copy },
{ label: "Good reply", icon: ThumbsUp },
{ label: "Write it again", icon: RotateCcw },
].map((action) => (
<button key={action.label} type="button" aria-label={action.label} className="grid h-10 w-10 place-items-center rounded-full transition hover:bg-stone-100 hover:text-stone-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600">
<action.icon aria-hidden="true" className="h-4 w-4" />
</button>
))}
</motion.div>
)}
</div>
</motion.div>
),
)}
{thinking && (
<motion.div key="typing" initial={reduce ? false : { opacity: 0, y: 10, scale: 0.9 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.12 } }} transition={{ type: "spring", stiffness: 420, damping: 26, delay: 0.15 }} className="flex origin-bottom-left items-center gap-3">
<span aria-hidden="true" className="grid h-7 w-7 flex-none place-items-center rounded-full bg-gradient-to-br from-emerald-400 to-teal-600 text-xs font-semibold text-white">W</span>
<span className="flex h-9 items-center gap-1.5 rounded-full bg-stone-100 px-4" role="status" aria-label="The assistant is typing">
{[0, 1, 2].map((dot) => (
<span key={dot} className="streaming-ai-chat-thread-dot h-2 w-2 rounded-full bg-emerald-600" style={{ animationDelay: `${dot * 0.16}s` }} />
))}
</span>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="border-t border-stone-100 px-4 pb-4 pt-3">
<div className="mb-3 flex gap-2 overflow-x-auto">
{suggestions.map((text) => (
<button key={text} type="button" onClick={() => ask(text)} className="h-10 flex-none rounded-full border border-stone-200 px-3.5 text-xs font-medium text-stone-700 transition hover:border-emerald-600 hover:text-emerald-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600">
{text}
</button>
))}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
ask(draft);
}}
className="flex items-center gap-2 rounded-full bg-stone-100 py-1.5 pl-4 pr-1.5 focus-within:ring-2 focus-within:ring-emerald-600"
>
<label htmlFor="streaming-ai-chat-thread-input" className="sr-only">
Message Wayfare
</label>
<input id="streaming-ai-chat-thread-input" value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Ask about your trip..." className="h-10 min-w-0 flex-1 bg-transparent text-[15px] text-stone-900 placeholder:text-stone-500 focus:outline-none" />
<button type="submit" aria-label="Send message" className="grid h-10 w-10 flex-none place-items-center rounded-full bg-emerald-600 text-white transition hover:bg-emerald-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2">
<ArrowUp aria-hidden="true" className="h-4 w-4" strokeWidth={2.5} />
</button>
</form>
</div>
</motion.div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/streaming-ai-chat-thread-with-typing-indicator.json
Each assistant message stores its full text and a `shown` count. A timer raises the count by one every 34 milliseconds, and only that many words are rendered. Every word is an inline-block span with a short CSS animation, so new words fade in while earlier ones stay still.
Replace the timer in the main effect. Append an assistant message when your request starts, then append each chunk to its `text` and set `shown` to the new word count. The typing dots show for as long as the last message belongs to the user.
Word steps read more smoothly at speed and need far fewer renders. If you want a typewriter feel, split with `text.split("")` in `wordsOf` and lower the delay to about 12 milliseconds.
An effect sets `scrollTop` to `scrollHeight` each time the messages change, so the newest line stays in view. If you want to let readers scroll back during a stream, only do this when they are already near the bottom.
No. The timer only runs while the section is in the viewport, using the `useInView` hook, and it stops looping after the first real message.
Related: Glowing AI Prompt Box with Morphing Send Button, Assistant Side Panel with Thinking Shimmer, Voice Input Bar with Live Waveform.
More ai prompt and chat ui or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.