More Layouts Snippets
AI Streaming Response UI — HTML CSS JS Snippet
AI Streaming Response · Layouts · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
AI Streaming Response — Token-by-Token Text Reveal, Safe HTML Slicing, Blinking Cursor & Stop Button

Every AI chat product — ChatGPT, Claude, Gemini, Perplexity — renders answers as a live stream rather than waiting for the full completion. The reason is perceived latency: a full response might take 5–20 seconds to generate, but the first token arrives in a few hundred milliseconds. Streaming that first token to the screen immediately makes the app feel instant. This snippet recreates the complete streaming-response experience in vanilla HTML, CSS, and JavaScript: rich text and code blocks revealed chunk by chunk, a blinking cursor that rides the leading edge of the text, a live token counter, a "Stop generating" button, and copy/regenerate actions that appear when the stream finishes.
How real streaming works: SSE deltas and the read loop
Production AI APIs stream via Server-Sent Events or chunked transfer encoding. The client calls fetch() with an AbortController signal, grabs res.body.getReader(), and loops on reader.read() — each resolved chunk is a small delta of new tokens which gets decoded and appended to a growing buffer. The UI then re-renders the buffer. This snippet simulates that pipeline with a setInterval that reveals 2–6 characters per 45ms tick (mimicking variable token sizes), so the visual behaviour — bursty, slightly irregular text arrival — matches what a real SSE stream looks like. Swapping the simulator for a real stream means replacing one function: the interval callback becomes the body of your reader.read() loop.
The hard problem: revealing HTML without breaking tags
Streaming plain text is trivial — el.textContent = buffer.slice(0, n). Streaming *rich* text is not: if the buffer contains <strong>token</strong> and you slice it at character 4, you inject the broken fragment <str into the DOM. This snippet solves it with a safeSlice() function that walks the source string tracking whether the pointer is inside a tag (between < and >). Only characters outside tags count toward the visible-character budget, and the cut position is always advanced past any half-open tag. The result is that bold text, inline code, and links materialise correctly mid-stream — the same technique production chat UIs use before they graduate to incremental markdown AST parsing.
Block-based rendering: paragraphs, lists, and code
The response is modelled as an ordered array of typed blocks — p, h3, ul, and code — mirroring how a markdown parser tokenises a completion. Paragraphs and headings stream character by character; list items stream one <li> at a time with per-item character reveal; code blocks stream line by line inside a styled .code-block container with a language label header and pre-highlighted syntax spans (.tok-kw for keywords, .tok-str for strings, .tok-fn for function calls, .tok-cm for comments). Line-by-line reveal for code is deliberate: character-level reveal inside syntax-highlighted markup looks glitchy because highlight spans pop in and out.
The cursor, the stop button, and the finished state
The blinking cursor is a single <span class="stream-cursor"> — an 8×15px rounded rectangle animated with steps(2, start) blink keyframes — that is *re-appended* to whichever element is currently receiving text, so it always rides the leading edge without any position math. While streaming, a pill-shaped "Stop generating" button with a red square icon is visible; clicking it calls finish(), which in a real app would also call controller.abort() to cancel the fetch and stop token billing. On finish, the cursor is removed and the footer fades in via an opacity transition, revealing the token count and the copy/regenerate icon buttons — the exact affordance sequence users know from ChatGPT and Claude.
Build with AI
Build, Understand, Optimize, and Extend It With AI
The most instructive part of this snippet is invisible until it breaks: paste the HTML, CSS, and JS into an AI assistant like Claude and ask it to explain what would happen if safeSlice() were replaced with a plain string slice — then ask it to show you the exact frame where "<strong>" gets cut in half. From there, ask it to upgrade the simulator into a real client: have it write the fetch + AbortController + getReader() loop against your actual chat endpoint, parse SSE data: lines, and keep the existing cursor and stop-button behaviour intact. It can also extend the block renderer — ask for a blockquote type, a streaming markdown table, or a collapsible "reasoning" section that streams before the answer like modern thinking models. If you are moving to React, ask it to convert the imperative DOM writes into a ref-based component with requestAnimationFrame batching and explain why per-token setState would tank performance. The snippet is a working scale model of a production streaming UI; an AI assistant is the fastest way to scale it up.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a ChatGPT-style streaming AI response UI in plain HTML, CSS, and JavaScript that replays a rich-text answer token by token — no frameworks, no API key required.
Requirements:
- Model the response as an ordered array of typed content blocks: paragraphs, a heading, a bulleted list, and a syntax-highlighted code block with a language label header, mirroring how a markdown AST would tokenise a model completion.
- Reveal paragraph and list text a few characters at a time on a fast interval with randomised chunk sizes so arrival feels bursty like real token sampling; reveal code blocks one whole line at a time to avoid highlight flicker.
- Inline HTML such as bold and inline code must stream without ever injecting a truncated tag: implement a tag-aware slice function that tracks whether the cut position is inside markup and only counts visible characters toward the reveal budget.
- A blinking block cursor must always sit at the leading edge of the newest text; achieve this by re-appending a single cursor element to whichever node is currently receiving characters rather than calculating positions.
- While streaming, show a centred pill-shaped "Stop generating" button with a red square icon that immediately finalises the response; explain in a comment where a real implementation would call AbortController.abort() to cancel the fetch and stop token billing.
- Track and display a live token count in tabular figures, and when the stream completes (or is stopped), remove the cursor and fade in a footer row containing the final count plus copy-to-clipboard and regenerate icon buttons — regenerate replays the stream from the start.
- Style it as a dark chat interface: right-aligned user bubble, gradient AI avatar, slate code blocks, and comment the code explaining exactly which function to replace with a real fetch + getReader() SSE consumption loop.Step by step
How to Use
- 1Watch the stream and use the controlsThe response begins streaming automatically: paragraphs and list items reveal character by character with the blinking cursor at the leading edge, and the code block reveals line by line. Click "Stop generating" to end the stream early, the copy icon to copy the response text to the clipboard, or the regenerate icon to replay the stream from the start. The token counter increments live as chunks arrive.
- 2Replace the simulator with a real SSE streamDelete the setInterval in stream() and use: const controller = new AbortController(); const res = await fetch("/api/chat", { method: "POST", body: JSON.stringify({ messages }), signal: controller.signal }); const reader = res.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value); render(buffer); }. Wire the Stop button to controller.abort().
- 3Edit the response contentThe RESPONSE array in the JS panel defines the blocks: { type: "p", text: "..." } for paragraphs, { type: "h3" } for headings, { type: "ul", items: [...] } for lists, and { type: "code", lang, lines } for code blocks. Inline HTML like <strong> and <code> is allowed in text — safeSlice() guarantees tags are never cut mid-stream. Add your own block types (blockquote, table) by extending buildBlock().
- 4Tune the streaming speed and feelTwo numbers control pacing: the 45 in setInterval(..., 45) is the tick interval, and "2 + Math.floor(Math.random() * 5)" is the characters revealed per tick. Real model output arrives at roughly 30–100 tokens/second; the defaults simulate ~60. For a slower, more deliberate feel use interval 60 with 1–3 chars; for a fast model use interval 30 with 4–8 chars.
- 5Render real markdown instead of pre-built blocksIn production the deltas are raw markdown, not typed blocks. Parse the accumulated buffer on every frame with a streaming-tolerant parser (marked or markdown-it both handle incomplete input gracefully), then set innerHTML through a sanitiser like DOMPurify — never inject unsanitised model output. Debounce parsing to once per animation frame with requestAnimationFrame so long responses stay at 60fps.
- 6Export to your frameworkClick JSX for a React component. Store the visible buffer in a ref (not state) and update the DOM imperatively or via a memoised markdown component — putting every delta through setState re-renders the whole tree hundreds of times per response. Pairs naturally with the AI Chat Interface shell, the AI Thinking Loader for the pre-first-token wait, and the Typing Indicator.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Server-side, create an API route that calls the model with streaming enabled and forwards the response body (in Next.js: return new Response(stream) from a route handler — both the OpenAI and Anthropic SDKs expose a .toReadableStream() or event iterator). Client-side, replace the setInterval simulator in stream() with the fetch + getReader() loop shown in the how-to-use section: accumulate decoder.decode(value) into a buffer, parse the SSE "data:" lines to extract each delta's text, append it to the current block, and re-render. Keep the AbortController in scope and call controller.abort() from the Stop button so the server stops generating — otherwise you pay for tokens the user never sees.
Because a naive slice can cut inside an HTML tag. If the buffer is "over <strong>300ms</strong>" and you slice at character 8, you inject "over <st" — the browser then either renders the literal text or silently drops it, and when the next frame completes the tag the DOM flashes. safeSlice() walks the string with an inTag boolean: characters between < and > never count toward the visible budget, and the slice point always advances to the closing > of any tag it lands inside. This gives glitch-free progressive reveal of bold, code, and links. For full markdown streaming in production, the more robust approach is re-parsing the whole buffer each frame with a forgiving parser plus DOMPurify sanitisation.
The naive approach — useState(buffer) updated on every delta — re-renders your component tree 500+ times per response and will drop frames on long answers. Instead, hold the buffer in a useRef and write to the DOM imperatively inside the read loop (contentRef.current.innerHTML = render(buffer)), or batch updates with requestAnimationFrame so React state changes at most 60 times per second: schedule setBuffer(ref.current) in an rAF callback if one is not already pending. Libraries like Vercel's AI SDK (useChat) implement exactly this batching internally, which is why they feel smooth.
Yes. The Tailwind export maps the classes directly: the user bubble is bg-indigo-500 text-white px-4 py-3 rounded-2xl rounded-br, the avatar is bg-gradient-to-br from-indigo-500 to-purple-500, code blocks are bg-slate-800 border border-slate-700 rounded-xl, and the cursor is an inline-block w-2 h-4 bg-indigo-300 animate-pulse span (or keep the custom steps() blink keyframe via a Tailwind plugin for the authentic terminal feel). In Angular, put the stream logic in a service that exposes a Signal<string> buffer, render with [innerHTML] piped through DomSanitizer.bypassSecurityTrustHtml only after DOMPurify, and run the interval outside the zone (NgZone.runOutsideAngular) so change detection is not triggered per token.