{"id":1210,"date":"2026-04-30T08:00:00","date_gmt":"2026-04-30T08:00:00","guid":{"rendered":"https:\/\/computercoursesonline.com\/?p=1210"},"modified":"2026-04-30T21:15:59","modified_gmt":"2026-04-30T21:15:59","slug":"designing-stable-interfaces-for-streaming-content","status":"publish","type":"post","link":"https:\/\/computercoursesonline.com\/index.php\/2026\/04\/30\/designing-stable-interfaces-for-streaming-content\/","title":{"rendered":"Designing Stable Interfaces For Streaming Content"},"content":{"rendered":"

Designing Stable Interfaces For Streaming Content<\/title><\/p>\n<article>\n<header>\n<h1>Designing Stable Interfaces For Streaming Content<\/h1>\n<address>Joas Pambou<\/address>\n<p> 2026-04-30T08:00:00+00:00<br \/>\n 2026-04-30T20:36:58+00:00<br \/>\n <\/header>\n<p>More interfaces now render while the response is still being generated. The UI begins in one state, then updates as more data comes in. You see this in chat apps, logs, transcription tools, and other real-time systems.<\/p>\n<p>The tricky part is that the <strong>interface is not in a fixed state<\/strong>; it keeps changing as new content comes in. It grows where lines become longer and new blocks appear. Something that was just below the screen can suddenly move, and the user\u2019s scroll position becomes harder to manage. Parts of the UI might even be incomplete while the user is already interacting with it.<\/p>\n<p>In this article, we\u2019ll take a simple interface and make it handle this properly. We\u2019ll look at how to keep things stable, manage scrolling, and render partial content without breaking the reading experience.<\/p>\n<h2 id=\"what-does-a-streaming-ui-actually-look-like\">What Does A Streaming UI Actually Look Like?<\/h2>\n<p>I\u2019ve built three demos that stream content in different ways: a chat bubble, a log feed, and a transcription view. They look different on the surface, but they all run into the same three problems.<\/p>\n<p>The first is <strong>scroll<\/strong>. When content is streaming in, most interfaces keep the viewport pinned to the bottom. That works if you are just watching, but the moment you scroll up to read something, the page snaps back down. You did not ask for that. The interface decided for you, and now you\u2019re fighting it instead of reading.<\/p>\n<p>The second is <strong>layout shift<\/strong>. Streaming content means containers are constantly growing, and as they do, everything below shifts downward. A button you were about to click is no longer where it was. A line you were reading has moved. The page is not broken; it is just that nothing stays still long enough to interact with comfortably.<\/p>\n<p>The third is <strong>render frequency<\/strong>. Browsers paint the screen around 60 times per second, but streams can arrive much faster than that. This means the DOM, which is the browser\u2019s internal representation of everything on the page, ends up being updated for frames the user will never actually see. Each update still costs something, and that cost adds up quietly until performance starts to slip.<\/p>\n<p>As you go through each demo, pay attention to where things start feeling off. That small moment of friction when the interface starts getting in your way. This is exactly what we are here to fix.<\/p>\n<div data-audience=\"non-subscriber\" data-remove=\"true\" class=\"feature-panel-container\">\n<aside class=\"feature-panel\">\n<div class=\"feature-panel-left-col\">\n<div class=\"feature-panel-description\">\n<p>Meet <strong><a data-instant href=\"https:\/\/www.smashingconf.com\/online-workshops\/\">Smashing Workshops<\/a><\/strong> on <strong>front-end, design & UX<\/strong>, with practical takeaways, live sessions, <strong>video recordings<\/strong> and a friendly Q&A. With Brad Frost, St\u00e9ph Walter and <a href=\"https:\/\/smashingconf.com\/online-workshops\/workshops\">so many others<\/a>.<\/p>\n<p><a data-instant href=\"smashing-workshops\" class=\"btn btn--green btn--large\">Jump to the workshops \u21ac<\/a><\/div>\n<\/div>\n<div class=\"feature-panel-right-col\"><a data-instant href=\"smashing-workshops\" class=\"feature-panel-image-link\"><\/p>\n<div class=\"feature-panel-image\">\n<img loading=\"lazy\" class=\"feature-panel-image-img lazyload\" src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Feature Panel\" width=\"257\" height=\"355\" data-src=\"\/images\/smashing-cat\/cat-scubadiving-panel.svg\"><\/p>\n<\/div>\n<p><\/a>\n<\/div>\n<\/aside>\n<\/div>\n<h2 id=\"example-1-streaming-ai-chat-responses\">Example 1: Streaming AI Chat Responses<\/h2>\n<p>This is the most familiar case. You click <strong>Stream<\/strong>, and the message starts growing token by token, just like a typical AI chat interface.<\/p>\n<figure class=\"\n \n \n \"><\/p>\n<p> <a href=\"https:\/\/codesandbox.io\/embed\/swmjpl?view=preview\"><\/p>\n<p> <img loading=\"lazy\" width=\"800\" height=\"566\" src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Streaming AI Chat Responses\" class=\"lazyload\" data-src=\"https:\/\/res.cloudinary.com\/indysigner\/image\/fetch\/f_auto,q_80\/w_400\/https:\/\/files.smashing.media\/articles\/designing-stable-interfaces-streaming-content\/1-streaming-ai-chat-responses.png\"><\/p>\n<p> <\/a><figcaption class=\"op-vertical-bottom\">\n Open in <a href=\"https:\/\/codesandbox.io\/embed\/swmjpl?view=preview\">CodeSandbox<\/a>. (<a href=\"https:\/\/files.smashing.media\/articles\/designing-stable-interfaces-streaming-content\/1-streaming-ai-chat-responses.png\">Large preview<\/a>)<br \/>\n <\/figcaption><\/figure>\n<p>Here\u2019s what I want you to try:<\/p>\n<ul>\n<li>Click the <strong>Stream<\/strong> button.<\/li>\n<li>Try scrolling upwards while the message is streaming.<\/li>\n<li>Increase the speed (to something like 10ms).<\/li>\n<\/ul>\n<p>You will notice something subtle but important: the UI keeps trying to pull you back down. Basically, it is making a decision for you about where your attention should be.<\/p>\n<p>That\u2019s one example. Let\u2019s look at another.<\/p>\n<h2 id=\"example-2-live-processing-in-a-log-viewer\">Example 2: Live Processing In A Log Viewer<\/h2>\n<p>This example looks different on the surface, but the problem is actually very similar to the first example. Rather than a message that gets longer over time, new lines are appended continuously, like a terminal or a log stream.<\/p>\n<p>The interesting part here is the tail toggle. It makes the trade-off between interaction and stable interfaces very clear:<\/p>\n<figure class=\"\n \n \n \"><\/p>\n<p> <a href=\"https:\/\/codesandbox.io\/embed\/cytscf?view=preview\"><\/p>\n<p> <img loading=\"lazy\" width=\"800\" height=\"515\" src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Live Processing In A Log Viewer\" class=\"lazyload\" data-src=\"https:\/\/res.cloudinary.com\/indysigner\/image\/fetch\/f_auto,q_80\/w_400\/https:\/\/files.smashing.media\/articles\/designing-stable-interfaces-streaming-content\/2-live-processing-log-viewer.png\"><\/p>\n<p> <\/a><figcaption class=\"op-vertical-bottom\">\n Open in <a href=\"https:\/\/codesandbox.io\/embed\/cytscf?view=preview\">CodeSandbox<\/a>. (<a href=\"https:\/\/files.smashing.media\/articles\/designing-stable-interfaces-streaming-content\/2-live-processing-log-viewer.png\">Large preview<\/a>)<br \/>\n <\/figcaption><\/figure>\n<p>Again, here is what I want you to try:<\/p>\n<ul>\n<li>Click the <strong>Start<\/strong> button.<\/li>\n<li>Allow the logs to stream past the container\u2019s height.<\/li>\n<li>Scroll up to the beginning.<\/li>\n<li>Stop the stream and disable the \u201ctail\u201d option.<\/li>\n<\/ul>\n<p>Notice that, when tail is enabled, the UI follows the new content. But you\u2019re unable to scroll up and stay in place. Instead, you need to stop the stream or enable \u201ctail\u201d to explore the content.<\/p>\n<h2 id=\"example-3-dashboard-displaying-real-time-metrics\">Example 3: Dashboard Displaying Real-Time Metrics<\/h2>\n<p>In this case, the UI updates in place:<\/p>\n<ul>\n<li>Numbers change,<\/li>\n<li>Charts shift,<\/li>\n<li>Values refresh continuously.<\/li>\n<\/ul>\n<figure class=\"\n \n \n \"><\/p>\n<p> <a href=\"https:\/\/codesandbox.io\/embed\/8rtsrm?view=preview\"><\/p>\n<p> <img loading=\"lazy\" width=\"800\" height=\"402\" src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Dashboard Displaying Real-Time Metrics\" class=\"lazyload\" data-src=\"https:\/\/res.cloudinary.com\/indysigner\/image\/fetch\/f_auto,q_80\/w_400\/https:\/\/files.smashing.media\/articles\/designing-stable-interfaces-streaming-content\/3-dashboard-display-real-time-metrics.png\"><\/p>\n<p> <\/a><figcaption class=\"op-vertical-bottom\">\n Open in <a href=\"https:\/\/codesandbox.io\/embed\/8rtsrm?view=preview\">CodeSandbox<\/a>. (<a href=\"https:\/\/files.smashing.media\/articles\/designing-stable-interfaces-streaming-content\/3-dashboard-display-real-time-metrics.png\">Large preview<\/a>)<br \/>\n <\/figcaption><\/figure>\n<p>There is no scroll tension this time, but a different issue shows up. That\u2019s what we\u2019ll get into next.<\/p>\n<h2 id=\"why-the-ui-feels-unstable-and-how-to-fix-it\">Why The UI Feels Unstable And How To Fix It<\/h2>\n<p>If you tried the chat demo and scrolled upward while the responses were coming in, you may have spotted the first issue right away: the UI keeps pulling you back down to the latest streamed content as it updates. This takes you out of context and never allows you the time to fully digest the content once it has passed.<\/p>\n<p>We see that exact same issue in the second example, the log viewer. Without the tail toggle, the streamed content overrides your scroll position.<\/p>\n<p>These aren\u2019t bugs in the traditional sense that they produce code errors; rather, they are accessibility issues that affect <em>all<\/em> users. That said, they can be fixed and prevented with careful UX considerations as you plan and test your work.<\/p>\n<h3 id=\"ensure-predictable-scroll-behavior\">Ensure Predictable Scroll Behavior<\/h3>\n<p>This is the goal:<\/p>\n<ul>\n<li>Enable auto-scrolling when detecting that the user is at the bottom of the stream.<\/li>\n<li>Stop auto-scrolling when the user has scrolled upwards.<\/li>\n<li>Resume auto-scrolling if the user scrolls back to the bottom of the stream.<\/li>\n<\/ul>\n<p>To do that, we need to know whether the user has intentionally moved away from the bottom, which we can assume is true when the scroll position is manually changed. We can track that behavior with a flag.<\/p>\n<pre><code class=\"language-javascript\">let userScrolled = false;\n\nchatEl.addEventListener('scroll', () => {\n const gap = chatEl.scrollHeight\n - chatEl.scrollTop\n - chatEl.clientHeight;\n\n userScrolled = gap > 60;\n});\n<\/code><\/pre>\n<p>That <code>60px<\/code> threshold matters. Without it, tiny layout changes (like a new line) would briefly create a gap and break auto-scroll, even if the user didn\u2019t actually scroll.<\/p>\n<p>Now let\u2019s make sure that we enable auto-scrolling only when the user\u2019s scroll position is equal to the stream\u2019s scroll height, i.e., the user is at the bottom of the stream:<\/p>\n<pre><code class=\"language-javascript\">function autoScroll() {\n if (!userScrolled) {\n chatEl.scrollTop = chatEl.scrollHeight;\n }\n}\n<\/code><\/pre>\n<p>One small thing that\u2019s easy to miss: we need to reset <code>userScrolled<\/code> once a new stream begins. Otherwise, one scroll from a previous message can silently disable auto-scroll for the next one.<\/p>\n<h3 id=\"solidify-layout-stability\">Solidify Layout Stability<\/h3>\n<p>We saw this in the first example as well. As new content streams in, the layout jumps, or shifts, taking you out of your current context. To be specific about what\u2019s shifting: it\u2019s not the page layout in a broad sense, it\u2019s the content directly below the chat bubble.<\/p>\n<p>There\u2019s also a subtler artifact worth calling out before we look at the code: cursor flicker. Because we\u2019re wiping <code>innerHTML<\/code> and recreating every element on every tick, the cursor is being destroyed and re-added constantly, up to 80 times per second at fast speeds.<\/p>\n<p>At normal speed, it\u2019s easy to miss, but slow the slider down to around 30ms, and you\u2019ll see a faint but persistent flicker at the end of the text. Once we fix the rebuild pattern, the flicker disappears entirely.<\/p>\n<figure class=\"video-embed-container break-out\">\n<div class=\"video-embed-container--wrapper\"><\/div>\n<\/figure>\n<p>That rebuild pattern is right here; this is what runs on every single incoming character:<\/p>\n<pre><code class=\"language-javascript\">bubble.innerHTML = '';\n\nfullText.split('n').forEach(line => {\n const p = document.createElement('p');\n p.textContent = line || 'u00A0';\n bubble.appendChild(p);\n});\n\nbubble.appendChild(cursorEl);\n<\/code><\/pre>\n<p>This works, but it\u2019s expensive. Every update wipes the DOM and rebuilds it, forcing layout recalculation each time.<\/p>\n<p>Now we write directly into a live node:<\/p>\n<pre><code class=\"language-javascript\">let currentP = null;\n\nfunction initBubble(bubble, cursor) {\n currentP = document.createElement('p');\n currentP.appendChild(document.createTextNode(''));\n bubble.insertBefore(currentP, cursor);\n}\n<\/code><\/pre>\n<p>What we can do next is to create one paragraph with an empty text node and insert it before the cursor. That gives us a live node we can write into directly.<\/p>\n<p>Then, for each character that arrives:<\/p>\n<pre><code class=\"language-javascript\">function appendChar(char, bubble, cursor) {\n if (char === 'n') {\n currentP = document.createElement('p');\n currentP.appendChild(document.createTextNode(''));\n bubble.insertBefore(currentP, cursor);\n } else {\n currentP.firstChild.textContent += char;\n }\n}\n<\/code><\/pre>\n<p>For a regular character, we extend the text node by one character. The browser doesn\u2019t need to recalculate the layout for that; the text grew, but nothing moved. For a newline, we create a fresh paragraph and move <code>currentP<\/code> forward. Layout recalculates once for that new paragraph, and that\u2019s it.<\/p>\n<div class=\"partners__lead-place\"><\/div>\n<h3 id=\"render-frequency\">Render Frequency<\/h3>\n<p>This one is most visible in the first example, the chat UI. Even with scrolling and a layout fixed, we\u2019re still writing to the DOM on every single incoming character.<\/p>\n<p>When the stream is moving fast, you end up hammering the DOM with updates that don\u2019t actually matter. The fix is straightforward: hold the incoming text in a buffer instead of writing it out immediately. Once you\u2019ve collected enough, write it all to the DOM in one go; that\u2019s what a <strong>flush<\/strong> is.<\/p>\n<p>To pull this off, we keep a simple buffer and make sure we only schedule a single update at a time. When it fires, <code>requestAnimationFrame<\/code> takes everything that has built up and writes it to the DOM in one shot.<\/p>\n<pre><code class=\"language-javascript\">let pending = '';\nlet rafQueued = false;\n<\/code><\/pre>\n<p>When a new character streams in, we then add it to the buffer. If no flush is scheduled yet, we queue one:<\/p>\n<pre><code class=\"language-javascript\">function onChar(char) {\n pending += char;\n\n if (!rafQueued) {\n rafQueued = true;\n requestAnimationFrame(flush);\n }\n}\n<\/code><\/pre>\n<p>The <code>rafQueued<\/code> flag is important. Without it, every character would schedule its own frame, and you\u2019d end up with dozens of unnecessary flushes.<\/p>\n<p>When the flush fires, it drains the entire buffer in one pass:<\/p>\n<pre><code class=\"language-javascript\">function flush() {\n for (const char of pending) {\n appendChar(char);\n }\n pending = '';\n rafQueued = false;\n autoScroll();\n}\n<\/code><\/pre>\n<p>All the characters that arrive after the last frame are then rendered together, right before the browser paints them. Then we clear the buffer, reset the flag, and run auto-scroll once.<\/p>\n<pre><code class=\"language-javascript\">let userScrolled = false;\n\nchatEl.addEventListener('scroll', () => {\n const gap = chatEl.scrollHeight\n - chatEl.scrollTop\n - chatEl.clientHeight;\n\n userScrolled = gap > 60;\n});\n\nfunction autoScroll() {\n if (!userScrolled) {\n chatEl.scrollTop = chatEl.scrollHeight;\n }\n}\n<\/code><\/pre>\n<p>If the gap is small, we keep auto-scrolling. If it grows, we assume the user scrolled up, and we stop. That small threshold helps avoid jitter when new lines slightly change the height. Also, remember to reset <code>userScrolled<\/code> when a new stream starts.<\/p>\n<p>Once scrolling is under control, another issue becomes obvious. As the message grows, it keeps shifting:<\/p>\n<ul>\n<li>It starts as one line,<\/li>\n<li>It expands, then<\/li>\n<li>It pushes everything below it.<\/li>\n<\/ul>\n<p>Nothing is technically broken, but it doesn\u2019t feel stable. A common approach is to rebuild the whole message on every update:<\/p>\n<pre><code class=\"language-javascript\">bubble.innerHTML = '';\n\nfullText.split('n').forEach(line => {\n const p = document.createElement('p');\n p.textContent = line || 'u00A0';\n bubble.appendChild(p);\n});\n\nbubble.appendChild(cursorEl);\n<\/code><\/pre>\n<p>This works, but it is doing too much work. Every update destroys and rebuilds the DOM, forcing layout recalculation each time. That\u2019s why everything keeps shifting. The idea is to write into the current paragraph and only create a new one when we actually hit a line break.<\/p>\n<pre><code class=\"language-javascript\">let currentP = null;\n\nfunction initBubble(bubble, cursor) {\n currentP = document.createElement('p');\n currentP.appendChild(document.createTextNode(''));\n bubble.insertBefore(currentP, cursor);\n}\n<\/code><\/pre>\n<p>And then update it character by character:<\/p>\n<pre><code class=\"language-javascript\">function appendChar(char, bubble, cursor) {\n if (char === 'n') {\n currentP = document.createElement('p');\n currentP.appendChild(document.createTextNode(''));\n bubble.insertBefore(currentP, cursor);\n } else {\n currentP.firstChild.textContent += char;\n }\n}\n<\/code><\/pre>\n<p>Now we\u2019re no longer rebuilding everything. Most updates just extend a text node, which is cheap and doesn\u2019t trigger large layout shifts. It also fixes the small cursor flicker you might have noticed earlier, since we\u2019re no longer removing and re-adding it.<\/p>\n<p>At this point, the UI already feels better, but there is still something subtle going on. We are still updating the DOM on every character. At higher speeds, that becomes a lot of small updates, many of which you never actually see.<\/p>\n<p>Instead of rendering immediately, we can buffer the incoming characters and apply them once per frame.<\/p>\n<pre><code class=\"language-javascript\">let pending = '';\nlet rafQueued = false;\n\nfunction onChar(char) {\n pending += char;\n\n if (!rafQueued) {\n rafQueued = true;\n requestAnimationFrame(flush);\n }\n}\n<\/code><\/pre>\n<p>At this point, we\u2019re not touching the DOM yet, but only collecting characters as they arrive. Then, right before the next frame is painted, we flush everything at once:<\/p>\n<pre><code class=\"language-javascript\">function flush() {\n for (const char of pending) {\n appendChar(char);\n }\n\n pending = '';\n rafQueued = false;\n\n autoScroll();\n}\n<\/code><\/pre>\n<p>These separate two things that were previously tied together:<\/p>\n<ol>\n<li>How fast data arrives, and<\/li>\n<li>When the UI updates.<\/li>\n<\/ol>\n<p>The result looks the same, but the browser does less work, resulting in the UI feeling smoother, especially when the stream is set to a faster speed.<\/p>\n<figure class=\"\n \n \n \"><\/p>\n<p> <a href=\"https:\/\/codesandbox.io\/embed\/pk7tk5?view=preview\"><\/p>\n<p> <img loading=\"lazy\" width=\"800\" height=\"566\" src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Broken vs. fixed\" class=\"lazyload\" data-src=\"https:\/\/res.cloudinary.com\/indysigner\/image\/fetch\/f_auto,q_80\/w_400\/https:\/\/files.smashing.media\/articles\/designing-stable-interfaces-streaming-content\/4-broken-vs-fixed.png\"><\/p>\n<p> <\/a><figcaption class=\"op-vertical-bottom\">\n Open in <a href=\"https:\/\/codesandbox.io\/embed\/pk7tk5?view=preview\">CodeSandbox<\/a>. (<a href=\"https:\/\/files.smashing.media\/articles\/designing-stable-interfaces-streaming-content\/4-broken-vs-fixed.png\">Large preview<\/a>)<br \/>\n <\/figcaption><\/figure>\n<p>None of these changes is a big effort on its own. But once they are in place, the interface stops reacting blindly to every update. It becomes easier to read, easier to control, and a lot less distracting, even though the content is still coming in continuously.<\/p>\n<p>There are even more considerations to take into account for ensuring a stable, predictable, and good user experience. For example, what happens if the stream is canceled mid-flow? And what can we do to ensure that user preferences are respected for things like reduced motion, keyboard navigation, and screen reader accessibility? Let\u2019s get into those next.<\/p>\n<h2 id=\"handling-interrupted-streams\">Handling Interrupted Streams<\/h2>\n<p>Most streaming interfaces include a way to stop or cancel the stream. We saw that in the demos. But stopping often leaves the UI in an awkward state. The cursor might keep blinking, buttons don\u2019t update, and the message just freezes mid-stream with no clear indication that it didn\u2019t finish.<\/p>\n<p>The problem is that the stop is usually wired to do one thing: cancel the timer. That\u2019s not enough. You also need to (1) clear the pending buffer, (2) remove the cursor, (3) mark the response as incomplete, and (4) reset the buttons. Here\u2019s how we accomplish those.<\/p>\n<h3 id=\"1-stop-the-stream-cleanly\">1. Stop The Stream Cleanly<\/h3>\n<p>Here\u2019s what <code>stopStream<\/code> needs to do, in order:<\/p>\n<ol>\n<li>Cancel the timer and flip the <code>isStreaming<\/code> flag so no more ticks run.<\/li>\n<li>Clear the <code>requestAnimationFrame<\/code> (RAF) buffer so nothing still queued gets written on the next frame.<\/li>\n<\/ol>\n<pre><code class=\"language-javascript\">function stopStream() {\n clearTimeout(streamTimer);\n isStreaming = false;\n pending = '';\n rafQueued = false;\n}\n<\/code><\/pre>\n<p>Clearing the <code>pending<\/code> property matters because there might be characters buffered from the last stream instance that haven\u2019t been flushed yet. If you don\u2019t clear it, the next <code>requestAnimationFrame<\/code> fires, drains the buffer, and writes those characters to the DOM after the stream has officially stopped.<\/p>\n<p>Now we move on to removing the cursor by calling <code>markStopped<\/code> on the bubble:<\/p>\n<pre><code class=\"language-javascript\">if (cursorEl && cursorEl.parentNode) cursorEl.remove();\n markStopped(aiBubble);\n\n stopBtn.style.display = 'none';\n retryBtn.style.display = '';\n playBtn.style.display = '';\n setStatus('Stopped', 'stopped');\n chat.removeEventListener('scroll', onScroll);\n}\n<\/code><\/pre>\n<p>The <code>cursorEl.parentNode<\/code> check is there because <code>stopStream<\/code> is also called internally when a new message fires mid-stream, at which point the cursor might already be gone. Calling <code>remove()<\/code> on a detached node throws, so we check first.<\/p>\n<p><code>markStopped<\/code> appends a small label to the bottom of the bubble so the user knows the response didn\u2019t finish:<\/p>\n<pre><code class=\"language-javascript\">function markStopped(bubble) {\n if (!bubble) return;\n bubble.classList.add('stopped');\n\n const label = document.createElement('span');\n label.className = 'stopped-label';\n label.textContent = 'response stopped';\n bubble.appendChild(label);\n}\n<\/code><\/pre>\n<p>The null check on <code>bubble<\/code> handles the edge case where stop fires before the AI message element has been initialized, which can happen if the user clicks stop during the 300ms delay before the bubble appears.<\/p>\n<h3 id=\"provide-a-retry-option\">Provide A Retry Option<\/h3>\n<p>If the stream simply stops — perhaps due to a network issue or some other unexpected error — we ought to provide the user with a path to re-attempt the stream. What that basically means is preventing the UI from doing the expensive work needed to scroll back up to the top, re-read the prompt, and retype it. With a retry option, the user only needs to click a button, and the stream restarts from the current position.<\/p>\n<p>To make that work, we need to hold onto the question when the stream starts:<\/p>\n<pre><code class=\"language-javascript\">let lastQuestion = '';\n\nfunction startStream(question, answer) {\n lastQuestion = question;\n \/\/ rest of setup...\n}\n<\/code><\/pre>\n<p>Then, when the retry attempt runs, we reset everything and start fresh:<\/p>\n<pre><code class=\"language-javascript\">function retryStream() {\n if (currentMsgEl && currentMsgEl.parentNode) {\n currentMsgEl.remove();\n }\n\n charIndex = 0;\n userScrolled = false;\n pending = '';\n rafQueued = false;\n isStreaming = true;\n\n retryBtn.style.display = 'none';\n stopBtn.style.display = '';\n setStatus('Streaming...', 'streaming');\n\n chat.addEventListener('scroll', onScroll, { passive: true });\n\n setTimeout(() => {\n initAIMsg();\n tick(lastAnswer);\n }, 200);\n}\n<\/code><\/pre>\n<p>The reset is critical. Every piece of state needs to go back to its initial value, just like a brand new stream.<\/p>\n<p><strong>Note:<\/strong> We remove the entire message row (<code>currentMsgEl<\/code>), not just the bubble. If only the bubble is removed, the layout wrapper and avatar remain persistent and break the structure.<\/p>\n<h3 id=\"send-a-new-message-mid-stream\">Send A New Message Mid-Stream<\/h3>\n<p>There\u2019s one more edge case that\u2019s easy to miss. If the user sends a new message while a stream is still running, you end up with two loops writing to the DOM at the same time. The result is messy, and characters from different responses get mixed together.<\/p>\n<p>Here\u2019s what to do: stop the current stream before starting a new one.<\/p>\n<pre><code class=\"language-javascript\">function startStream(question, answer) {\n if (isStreaming) {\n clearTimeout(streamTimer);\n isStreaming = false;\n pending = '';\n rafQueued = false;\n if (cursorEl && cursorEl.parentNode) cursorEl.remove();\n chat.removeEventListener('scroll', onScroll);\n }\n\n \/\/ now reset and start fresh\n charIndex = 0;\n userScrolled = false;\n isStreaming = true;\n lastQuestion = question;\n \/\/ ...\n}\n<\/code><\/pre>\n<p>Here, we inline the cleanup rather than calling <code>stopStream<\/code> directly because <code>stopStream<\/code> also calls <code>markStopped<\/code> and resets the buttons. The next demo has all three behaviors wired up. You can start a stream, hit \u201cStop\u201d mid-stream, and the cursor disappears, the \u201cresponse stopped\u201d label appears, and a \u201cRetry\u201d buttons displayed.<\/p>\n<figure class=\"\n \n \n \"><\/p>\n<p> <a href=\"https:\/\/codesandbox.io\/embed\/9cfy92?view=preview\"><\/p>\n<p> <img loading=\"lazy\" width=\"800\" height=\"505\" src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Interruptible stream\" class=\"lazyload\" data-src=\"https:\/\/res.cloudinary.com\/indysigner\/image\/fetch\/f_auto,q_80\/w_400\/https:\/\/files.smashing.media\/articles\/designing-stable-interfaces-streaming-content\/5-interruptible-stream.png\"><\/p>\n<p> <\/a><figcaption class=\"op-vertical-bottom\">\n Open in <a href=\"https:\/\/codesandbox.io\/embed\/9cfy92?view=preview\">CodeSandbox<\/a>. (<a href=\"https:\/\/files.smashing.media\/articles\/designing-stable-interfaces-streaming-content\/5-interruptible-stream.png\">Large preview<\/a>)<br \/>\n <\/figcaption><\/figure>\n<div class=\"partners__lead-place\"><\/div>\n<h2 id=\"accessibility\">Accessibility<\/h2>\n<p>Streaming interfaces are often built and tested with a mouse, so they may feel just fine in a browser, but break down in other situations that may not have been considered, like whether a screen reader announces new content at all. Or navigating with a keyboard might get stuck or lose focus as things update. And, of course, moving text can be uncomfortable — or even disabling — for <a href=\"https:\/\/www.smashingmagazine.com\/2021\/10\/respecting-users-motion-preferences\/\">those with motion sensitivities<\/a>.<\/p>\n<p>The good part is that you do not need to rebuild everything to accommodate these things; they can be fixed with solutions that sit on top of what is already there.<\/p>\n<h3 id=\"accommodating-assistive-technology-with-live-regions\">Accommodating Assistive Technology With Live Regions<\/h3>\n<p>Screen readers don\u2019t automatically announce content that shows up on its own. They usually read things when the user moves to them. So, in a streaming UI, where text builds up over time, nothing gets announced. The content is there, but the user doesn\u2019t hear anything.<\/p>\n<p>The fix is <a href=\"https:\/\/w3c.github.io\/aria\/#aria-live\"><code>aria-live<\/code><\/a>. It tells the browser to watch a container and announce updates as they happen, without the user needing to move focus.<\/p>\n<pre><code class=\"language-html\"><div\n id=\"chat\"\n role=\"log\"\n aria-live=\"polite\"\n aria-atomic=\"false\"\n aria-label=\"Chat messages\"\n><\/div>\n<\/code><\/pre>\n<ul>\n<li><code>role="log"<\/code> tells assistive tech this is a stream of updates, like a running transcript. Some tools handle this automatically, but it\u2019s safer to be explicit so behavior stays consistent.<\/li>\n<li><code>aria-atomic="false"<\/code> makes sure only the new content is announced. Without it, some screen readers try to read the whole message again on every update, which quickly becomes unusable.<\/li>\n<li><code>aria-live="polite"<\/code> queues updates instead of interrupting. Use <code>assertive<\/code> only for things that really need immediate attention, like errors.<\/li>\n<\/ul>\n<h3 id=\"handling-incomplete-states\">Handling Incomplete States<\/h3>\n<p>Earlier, we inserted a \u201cResponse Stopped\u201d label to the message when the stream stops mid-stream. Visually, that\u2019s enough. But for a screen reader, that change needs to be announced.<\/p>\n<p>Since the message is inside a live region with <code>aria-live="polite"<\/code>, the label will be automatically announced as new content when it\u2019s added to the DOM. The live region already handles the announcement, so no additional ARIA is needed on the label itself.<\/p>\n<p>The <strong>Retry<\/strong> button that appears next also needs context. If a screen reader simply says \u201cRetry, button,\u201d it\u2019s not clear what action that refers to. You can fix that by adding an <code>aria-label<\/code> that includes the original question:<\/p>\n<pre><code class=\"language-javascript\">retryBtn.setAttribute(\n 'aria-label',\n `Retry: ${lastQuestion.slice(0, 60)}`\n);\n<\/code><\/pre>\n<p>What you can do here is to set this label when the button appears, not on page load:<\/p>\n<pre><code class=\"language-javascript\">retryBtn.style.display = 'inline-block';\nretryBtn.setAttribute(\n 'aria-label',\n `Retry: ${lastQuestion.slice(0, 60)}`\n);\n<\/code><\/pre>\n<p>We also call <code>retryBtn.focus()<\/code> after stopping. That way, keyboard users don\u2019t have to <code>Tab<\/code> around with the keyboard to find the next action.<\/p>\n<p><strong>Testing with assistive technology:<\/strong> Don\u2019t rely on assumptions about how screen readers announce this. Test with actual tools like NVDA (Windows), JAWS (Windows), or VoiceOver (Mac\/iOS). Browser DevTools can show you what\u2019s exposed in the accessibility tree, but they can\u2019t tell you how the content <em>sounds<\/em>. A real screen reader will reveal whether the announcement is happening at the right time and in the right way.<\/p>\n<h3 id=\"account-for-keyboard-navigation\">Account For Keyboard Navigation<\/h3>\n<p>The controls need to work with the keyboard while the UI is live, so the Stop button has to be reachable. For someone not using a mouse, <kbd>Tab<\/kbd> + <kbd>Enter<\/kbd> is the only way to cancel a running stream.<\/p>\n<p>Using <code>display: none<\/code> is fine for hiding buttons; it removes them from the tab order. The problem is using things like <code>opacity: 0<\/code> or <code>visibility: hidden<\/code>. Those hide elements visually, but they can still receive focus, so users end up tabbing onto something they can\u2019t see.<\/p>\n<p>Use <code>:focus-visible<\/code> so the focus ring shows up for keyboard navigation, but not for mouse clicks:<\/p>\n<pre><code class=\"language-css\">btn:focus-visible {\n outline: 2px solid #1d9e75;\n outline-offset: 2px;\n}\n<\/code><\/pre>\n<p>The cursor inside the message should have <code>aria-hidden="true"<\/code>. It\u2019s just visual. Without that, some screen readers try to read it as text, which gets distracting.<\/p>\n<h3 id=\"motion-sensitivity\">Motion Sensitivity<\/h3>\n<p>The typewriter effect we see in practically every AI interface produces constant motion. As we\u2019ve already discussed, certain amounts of motion can be disabling. Thankfully, browsers expose <code>prefers-reduced-motion<\/code>, which detects a user\u2019s motion preferences at the operating system level.<\/p>\n<p>For streaming, the best approach is simple: skip the animation and render the full response at once. The content stays the same, only without the motion.<\/p>\n<pre><code class=\"language-javascript\">const reducedMotion = window.matchMedia(\n '(prefers-reduced-motion: reduce)'\n).matches;\n<\/code><\/pre>\n<pre><code class=\"language-javascript\">if (reducedMotion) {\n initAIMsg();\n for (const char of text) appendChar(char);\n if (cursorEl && cursorEl.parentNode) cursorEl.remove();\n done();\n return;\n}\ntick(text); \/\/ normal animation\n<\/code><\/pre>\n<p>In CSS, the cursor blink also needs to stop. Despite being a minor detail, a blinking cursor element counts as <a href=\"https:\/\/www.w3.org\/WAI\/WCAG21\/Understanding\/three-flashes-or-below-threshold.html\">flashing content<\/a>.<\/p>\n<pre><code class=\"language-css\">@media (prefers-reduced-motion: reduce) {\n .cursor { animation: none; opacity: 1; }\n}\n<\/code><\/pre>\n<p>There we go! The demo below puts everything from this article together, so you can see how these patterns work in practice. It also includes a reduced motion toggle, so you can test the instant render version easily.<\/p>\n<figure class=\"\n \n \n \"><\/p>\n<p> <a href=\"https:\/\/codesandbox.io\/embed\/vd9mnk?view=preview\"><\/p>\n<p> <img loading=\"lazy\" width=\"800\" height=\"594\" src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Accessible streaming\" class=\"lazyload\" data-src=\"https:\/\/res.cloudinary.com\/indysigner\/image\/fetch\/f_auto,q_80\/w_400\/https:\/\/files.smashing.media\/articles\/designing-stable-interfaces-streaming-content\/6-accessible-streaming.png\"><\/p>\n<p> <\/a><figcaption class=\"op-vertical-bottom\">\n Open in <a href=\"https:\/\/codesandbox.io\/embed\/vd9mnk?view=preview\">CodeSandbox<\/a>. (<a href=\"https:\/\/files.smashing.media\/articles\/designing-stable-interfaces-streaming-content\/6-accessible-streaming.png\">Large preview<\/a>)<br \/>\n <\/figcaption><\/figure>\n<h2 id=\"conclusion\">Conclusion<\/h2>\n<p>Streaming itself is mostly solved. Getting data from the server to the client is not the hard part anymore. What breaks is the UI on top of it.<\/p>\n<p>When content updates continuously, small things start to matter, like scroll behavior, layout stability, render timing, and how the interface responds to user actions. If those aren\u2019t handled well, the UI feels unstable and hard to use.<\/p>\n<p>The patterns in this article fix that by:<\/p>\n<ul>\n<li>Keeping scroll position under the user\u2019s control,<\/li>\n<li>Updating only what has changed,<\/li>\n<li>Batching renders per frame,<\/li>\n<li>Handling stop and retry actions, and<\/li>\n<li>Making the interface accessible.<\/li>\n<\/ul>\n<p>You don\u2019t need all of these every time. But when streaming is involved, these are the places things usually go wrong.<\/p>\n<h3 id=\"further-reading\">Further Reading<\/h3>\n<ul>\n<li><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Server-sent_events\/Using_server-sent_events\">Using Server-Sent Events<\/a><br \/>\nHow to open a connection, handle events, and reconnect when needed. This is the transport layer, everything here builds on.<\/li>\n<li><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Streams_API\">Streams API<\/a><br \/>\nStreaming data directly from <code>fetch<\/code>. Useful when you need more control than SSE.<\/li>\n<li><a href=\"https:\/\/developer.chrome.com\/docs\/devtools\/performance\">Chrome DevTools Performance panel<\/a><br \/>\nHelps you see layout recalculations and paint costs, so you can verify performance improvements.<\/li>\n<li>\u201c<a href=\"https:\/\/web.dev\/articles\/dom-size-and-interactivity\">How Large DOM Sizes Affect Interactivity, And What You Can Do About It<\/a>\u201d, Jeremy Wagner<br \/>\nWhy large DOM trees slow things down, and how to keep them under control in long streaming sessions.<\/li>\n<\/ul>\n<div class=\"signature\">\n <img src=\"data:image\/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Smashing Editorial\" width=\"35\" height=\"46\" loading=\"lazy\" class=\"lazyload\" data-src=\"https:\/\/www.smashingmagazine.com\/images\/logo\/logo--red.png\"><br \/>\n <span>(yk)<\/span>\n<\/div>\n<\/article>\n","protected":false},"excerpt":{"rendered":"<p>Designing Stable Interfaces For Streaming Content Designing Stable Interfaces For Streaming Content Joas Pambou 2026-04-30T08:00:00+00:00 2026-04-30T20:36:58+00:00 More interfaces now render while the response is still being generated. The UI begins in one state, then updates as more data comes in. You see this in chat apps, logs, transcription tools, and other real-time systems. The tricky…<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":[],"categories":[13],"tags":[],"_links":{"self":[{"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/posts\/1210"}],"collection":[{"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/comments?post=1210"}],"version-history":[{"count":1,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/posts\/1210\/revisions"}],"predecessor-version":[{"id":1211,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/posts\/1210\/revisions\/1211"}],"wp:attachment":[{"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/media?parent=1210"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/categories?post=1210"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/computercoursesonline.com\/index.php\/wp-json\/wp\/v2\/tags?post=1210"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}