AI Typing Reply
A chat reply that shows a typing indicator, then streams the answer word by word with a blinking caret. Replayable.
Preview and code
index.html
<div class="ai">
<div class="ai-row me"><p>Can you summarise this plan in one line?</p></div>
<div class="ai-row bot"><span class="ai-av" aria-hidden="true">AI</span>
<div class="ai-bubble" aria-live="polite"><span class="ai-dots" aria-label="Assistant is typing"><b></b><b></b><b></b></span><span class="ai-text"></span></div></div>
<button class="ai-replay" type="button">Replay</button>
</div>
styles.css
.ai{width:min(400px,100%);display:grid;gap:10px;font-size:14px}
.ai-row{display:flex;gap:8px;align-items:flex-end}
.ai-row.me{justify-content:flex-end}
.ai-row.me p{margin:0;max-width:80%;padding:9px 13px;border-radius:16px 16px 4px 16px;background:var(--accent);color:#fff}
.ai-av{flex:none;width:28px;height:28px;border-radius:50%;display:grid;place-items:center;font:700 10px/1 system-ui;background:var(--ink);color:var(--stage)}
.ai-bubble{max-width:82%;min-height:38px;padding:9px 13px;border-radius:16px 16px 16px 4px;background:color-mix(in srgb,var(--ink) 7%,transparent);border:1px solid var(--line);color:var(--ink)}
.ai-dots{display:inline-flex;gap:4px;padding:6px 0}
.ai-dots[hidden]{display:none}
.ai-dots b{width:7px;height:7px;border-radius:50%;background:var(--muted);animation:ai-p 1.2s infinite ease-in-out}
.ai-dots b:nth-child(2){animation-delay:.15s}.ai-dots b:nth-child(3){animation-delay:.3s}
.ai-text.on::after{content:"";display:inline-block;width:2px;height:1em;margin-left:2px;vertical-align:-2px;background:var(--accent);animation:ai-c 1s steps(1) infinite}
.ai-replay{justify-self:center;border:1px solid var(--line);background:var(--stage);color:var(--ink);border-radius:999px;padding:7px 14px;font:600 12px/1 system-ui,sans-serif;cursor:pointer}
.ai-replay:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
@keyframes ai-p{0%,80%,100%{opacity:.35;transform:translateY(0)}40%{opacity:1;transform:translateY(-3px)}}
@keyframes ai-c{50%{opacity:0}}
@media (prefers-reduced-motion:reduce){.ai-dots b,.ai-text.on::after{animation:none}}
script.js
const dots=document.querySelector('.ai-dots'),out=document.querySelector('.ai-text');
const reply='A 6-step build: set up the starter, add the database, build booking, then test privacy with two accounts and deploy.';
let timers=[];
function play(){timers.forEach(clearTimeout);timers=[];out.textContent='';out.classList.remove('on');dots.hidden=false;
const words=reply.split(' ');timers.push(setTimeout(()=>{dots.hidden=true;out.classList.add('on');
words.forEach((w,i)=>timers.push(setTimeout(()=>{out.textContent+=(i?' ':'')+w;if(i===words.length-1)timers.push(setTimeout(()=>out.classList.remove('on'),600))},i*70+Math.random()*40)))},1300))}
document.querySelector('.ai-replay').onclick=play;play();
ai-typing-reply.tsx
"use client";
import { useEffect, useState } from "react";
const css = `
.ai-bubble{max-width:82%;min-height:38px;padding:9px 13px;border-radius:16px 16px 16px 4px;background:#f1f3f8;border:1px solid #d8deeb;color:#0b1020;font:14px/1.5 system-ui,sans-serif}
.ai-dots{display:inline-flex;gap:4px;padding:6px 0}
.ai-dots b{width:7px;height:7px;border-radius:50%;background:#5c627c;animation:ai-p 1.2s infinite ease-in-out}
.ai-dots b:nth-child(2){animation-delay:.15s}.ai-dots b:nth-child(3){animation-delay:.3s}
.ai-text.on::after{content:"";display:inline-block;width:2px;height:1em;margin-left:2px;vertical-align:-2px;background:#1659f5;animation:ai-c 1s steps(1) infinite}
@keyframes ai-p{0%,80%,100%{opacity:.35;transform:translateY(0)}40%{opacity:1;transform:translateY(-3px)}}
@keyframes ai-c{50%{opacity:0}}
@media (prefers-reduced-motion:reduce){.ai-dots b,.ai-text.on::after{animation:none}}`;
/**
* Pass the text received so far in `text` and `done` when the stream ends.
* While `text` is empty and not done, the typing dots show.
*/
export function AiTypingReply({ text, done }: { text: string; done: boolean }) {
return (
<div className="ai-bubble" aria-live="polite" aria-busy={!done}>
<style>{css}</style>
{!text && !done ? (
<span className="ai-dots" aria-label="Assistant is typing"><b /><b /><b /></span>
) : (
<span className={`ai-text${done ? "" : " on"}`}>{text}</span>
)}
</div>
);
}
/** Demo helper: fakes a stream so you can see it working before wiring a real one. */
export function useFakeStream(reply: string, delay = 1300, perWord = 70) {
const [text, setText] = useState("");
const [done, setDone] = useState(false);
useEffect(() => {
const words = reply.split(" ");
const timers: ReturnType<typeof setTimeout>[] = [];
timers.push(setTimeout(() => {
words.forEach((w, i) => timers.push(setTimeout(() => {
setText((t) => (t ? t + " " : "") + w);
if (i === words.length - 1) setDone(true);
}, i * perWord)));
}, delay));
return () => timers.forEach(clearTimeout);
}, [reply, delay, perWord]);
return { text, done };
}
Install
npx shadcn@latest add https://ownlyplans.com/r/bhaskar/ai-typing-reply.json
Adds the React files to components/ownlyplans/ in a project set up with shadcn and Tailwind CSS. Or copy them from the Code tab.
About
The two states every AI chat needs: “thinking” and “streaming”. The dots pulse while you wait, then the reply arrives a few words at a time with a caret, exactly like a streamed model response. Swap the fake stream for your real one: append each chunk you receive to the bubble.
The bubble is a live region, so screen readers announce the finished answer once.