{
    "id": "bhaskar/ai-typing-reply",
    "kind": "component",
    "title": "AI Typing Reply",
    "summary": "A chat reply that shows a typing indicator, then streams the answer word by word with a blinking caret. Replayable.",
    "author": "Admin",
    "stars": 0,
    "url": "https://ownlyplans.com/bhaskar/ai-typing-reply/",
    "stack": [
        "CSS",
        "HTML",
        "JavaScript",
        "React"
    ],
    "tools": [
        "claude-code",
        "cursor",
        "lovable"
    ],
    "difficulty": "beginner",
    "license": "mit",
    "version": "1.0",
    "overview": "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.\n\nThe bubble is a live region, so screen readers announce the finished answer once.",
    "steps": [],
    "files": [],
    "install": "npx shadcn@latest add https://ownlyplans.com/r/bhaskar/ai-typing-reply.json",
    "how_to_use": "Use the component code (html, css, js, or react) directly, or run the install command.",
    "component": {
        "category": "AI interfaces",
        "html": "<div class=\"ai\">\n  <div class=\"ai-row me\"><p>Can you summarise this plan in one line?</p></div>\n  <div class=\"ai-row bot\"><span class=\"ai-av\" aria-hidden=\"true\">AI</span>\n    <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>\n  <button class=\"ai-replay\" type=\"button\">Replay</button>\n</div>",
        "css": ".ai{width:min(400px,100%);display:grid;gap:10px;font-size:14px}\n.ai-row{display:flex;gap:8px;align-items:flex-end}\n.ai-row.me{justify-content:flex-end}\n.ai-row.me p{margin:0;max-width:80%;padding:9px 13px;border-radius:16px 16px 4px 16px;background:var(--accent);color:#fff}\n.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)}\n.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)}\n.ai-dots{display:inline-flex;gap:4px;padding:6px 0}\n.ai-dots[hidden]{display:none}\n.ai-dots b{width:7px;height:7px;border-radius:50%;background:var(--muted);animation:ai-p 1.2s infinite ease-in-out}\n.ai-dots b:nth-child(2){animation-delay:.15s}.ai-dots b:nth-child(3){animation-delay:.3s}\n.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}\n.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}\n.ai-replay:focus-visible{outline:2px solid var(--accent);outline-offset:2px}\n@keyframes ai-p{0%,80%,100%{opacity:.35;transform:translateY(0)}40%{opacity:1;transform:translateY(-3px)}}\n@keyframes ai-c{50%{opacity:0}}\n@media (prefers-reduced-motion:reduce){.ai-dots b,.ai-text.on::after{animation:none}}",
        "js": "const dots=document.querySelector('.ai-dots'),out=document.querySelector('.ai-text');\nconst reply='A 6-step build: set up the starter, add the database, build booking, then test privacy with two accounts and deploy.';\nlet timers=[];\nfunction play(){timers.forEach(clearTimeout);timers=[];out.textContent='';out.classList.remove('on');dots.hidden=false;\n const words=reply.split(' ');timers.push(setTimeout(()=>{dots.hidden=true;out.classList.add('on');\n  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))}\ndocument.querySelector('.ai-replay').onclick=play;play();",
        "react": "\"use client\";\nimport { useEffect, useState } from \"react\";\n\nconst css = `\n.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}\n.ai-dots{display:inline-flex;gap:4px;padding:6px 0}\n.ai-dots b{width:7px;height:7px;border-radius:50%;background:#5c627c;animation:ai-p 1.2s infinite ease-in-out}\n.ai-dots b:nth-child(2){animation-delay:.15s}.ai-dots b:nth-child(3){animation-delay:.3s}\n.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}\n@keyframes ai-p{0%,80%,100%{opacity:.35;transform:translateY(0)}40%{opacity:1;transform:translateY(-3px)}}\n@keyframes ai-c{50%{opacity:0}}\n@media (prefers-reduced-motion:reduce){.ai-dots b,.ai-text.on::after{animation:none}}`;\n\n/**\n * Pass the text received so far in `text` and `done` when the stream ends.\n * While `text` is empty and not done, the typing dots show.\n */\nexport function AiTypingReply({ text, done }: { text: string; done: boolean }) {\n  return (\n    <div className=\"ai-bubble\" aria-live=\"polite\" aria-busy={!done}>\n      <style>{css}</style>\n      {!text && !done ? (\n        <span className=\"ai-dots\" aria-label=\"Assistant is typing\"><b /><b /><b /></span>\n      ) : (\n        <span className={`ai-text${done ? \"\" : \" on\"}`}>{text}</span>\n      )}\n    </div>\n  );\n}\n\n/** Demo helper: fakes a stream so you can see it working before wiring a real one. */\nexport function useFakeStream(reply: string, delay = 1300, perWord = 70) {\n  const [text, setText] = useState(\"\");\n  const [done, setDone] = useState(false);\n  useEffect(() => {\n    const words = reply.split(\" \");\n    const timers: ReturnType<typeof setTimeout>[] = [];\n    timers.push(setTimeout(() => {\n      words.forEach((w, i) => timers.push(setTimeout(() => {\n        setText((t) => (t ? t + \" \" : \"\") + w);\n        if (i === words.length - 1) setDone(true);\n      }, i * perWord)));\n    }, delay));\n    return () => timers.forEach(clearTimeout);\n  }, [reply, delay, perWord]);\n  return { text, done };\n}",
        "dependencies": [],
        "install": "npx shadcn@latest add https://ownlyplans.com/r/bhaskar/ai-typing-reply.json",
        "licence_notice": ""
    }
}