AI Streaming Response UI — HTML CSS JS Snippet

AI Streaming Response · Layouts · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Character-level streaming with safeSlice(): reveals rich HTML without ever injecting a broken tag
Block-typed response model (p, h3, ul, code) mirroring a markdown AST — easy to extend
Code blocks stream line-by-line inside a styled container with language header and syntax-colour spans
Blinking cursor rides the leading edge by re-appending one span — no position calculation
Stop generating pill button, shown only while streaming; wire it to AbortController.abort()
Live token counter with tabular-nums so digits do not jitter as they change
Post-stream footer fade-in: copy-to-clipboard with success state and regenerate replay
Variable chunk sizes (2–6 chars per 45ms tick) simulate real SSE token burstiness

About this UI Snippet

AI Streaming Response — Token-by-Token Text Reveal, Safe HTML Slicing, Blinking Cursor & Stop Button

Screenshot of the AI Streaming Response snippet rendered live

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:

text
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

  1. 1
    Watch 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.
  2. 2
    Replace 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().
  3. 3
    Edit 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().
  4. 4
    Tune 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.
  5. 5
    Render 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.
  6. 6
    Export 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

Frontend for a ChatGPT-style assistant built on the OpenAI or Claude API
This is the response-rendering half of an AI chat product. Wire the stream() function to your API route: server-side, call the model with stream: true and pipe the SSE body through; client-side, replace the simulator loop with the reader.read() loop from step 2. The block renderer, cursor management, stop button, and finished-state footer are already production-shaped, so you only supply the transport. Combine with the AI Chat Interface for the full conversation shell.
Perceived-performance upgrade for any slow API response
Streaming is not only for LLMs. Any endpoint that takes seconds — report generation, search summarisation, data exports — feels dramatically faster when partial results paint immediately. Time-to-first-byte of 300ms with streaming beats a 6-second spinner every time in user testing. The block-based renderer here handles progressive rich content of any origin: stream log lines, generated document sections, or analysis bullet points using the same ul/p/code block types.
Streaming code answers in developer tools and docs assistants
The line-by-line code block reveal is purpose-built for coding assistants. Each line arrives whole with its syntax-highlight spans (.tok-kw, .tok-str, .tok-fn, .tok-cm) intact, avoiding the flicker of re-highlighting partial lines. The code-head bar shows the language label and has room for a copy button. For a docs Q&A bot, pair this with the Code Block Tabs snippet so users can flip between the streamed answer and full file context.
Product demos and landing-page hero mockups for AI startups
Because the stream is simulated locally, this snippet needs no API key and replays identically on every load — ideal for marketing pages that show the product "thinking". Drop it into a hero section, set the RESPONSE array to a flattering example answer, and let it loop with the regenerate button on a timer. The dark slate palette (#0f172a background, indigo-violet avatar gradient) matches the visual language users associate with AI products.
Learn safe incremental HTML rendering and SSE consumption patterns
Two transferable techniques live in this snippet. First, safeSlice() demonstrates tag-aware string slicing — walking a string with an inTag flag so markup is never truncated mid-tag, which applies to any progressive-reveal effect over rich text. Second, the stream() structure (buffer, per-tick append, render, finish) is exactly the shape of a real reader.read() consumption loop, so refactoring the simulator into genuine SSE handling is a one-function change and a good learning exercise.
Customer-support and onboarding bots inside SaaS dashboards
In-app help assistants benefit most from the stop button and regenerate affordances: support users frequently realise mid-answer that the bot misunderstood, and cancelling instantly (with controller.abort() stopping token spend server-side) is both better UX and cheaper. The compact 620px column fits a support drawer or side panel; pair it with the Floating Chat Widget launcher and the Emoji Reaction Bar for answer feedback.

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.