
Source: Articles on Smashing Magazine — For Web Designers And Developers | Read More
Streaming UIs are an easy concept on the surface, but are quite complicated in practice. There are many considerations that need to be accounted for, from layout shifts and motion preferences to proper markup and various states, that may not be instantly obvious. What happens if the stream is interrupted? Can users tab through the UI on the keyboard as it shifts? What ARIA attributes might be needed? Those are the sorts of things we will tackle in this article.
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 part is that the interface is not in a fixed state; 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’s scroll position becomes harder to manage. Parts of the UI might even be incomplete while the user is already interacting with it.
In this article, we’ll take a simple interface and make it handle this properly. We’ll look at how to keep things stable, manage scrolling, and render partial content without breaking the reading experience.
I’ve 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.
The first is scroll. 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’re fighting it instead of reading.
The second is layout shift. 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.
The third is render frequency. 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’s 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.
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.
This is the most familiar case. You click Stream, and the message starts growing token by token, just like a typical AI chat interface.

Here’s what I want you to try:
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.
That’s one example. Let’s look at another.
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.
The interesting part here is the tail toggle. It makes the trade-off between interaction and stable interfaces very clear:

Again, here is what I want you to try:
Notice that, when tail is enabled, the UI follows the new content. But you’re unable to scroll up and stay in place. Instead, you need to stop the stream or enable “tail” to explore the content.
In this case, the UI updates in place:

There is no scroll tension this time, but a different issue shows up. That’s what we’ll get into next.
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.
We see that exact same issue in the second example, the log viewer. Without the tail toggle, the streamed content overrides your scroll position.
These aren’t bugs in the traditional sense that they produce code errors; rather, they are accessibility issues that affect all users. That said, they can be fixed and prevented with careful UX considerations as you plan and test your work.
This is the goal:
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.
let userScrolled = false;
chatEl.addEventListener('scroll', () =>
const gap = chatEl.scrollHeight
- chatEl.scrollTop
- chatEl.clientHeight;
userScrolled = gap > 60;
The cursorEl.parentNode check is there because stopStream is also called internally when a new message fires mid-stream, at which point the cursor might already be gone. Calling remove() on a detached node throws, so we check first.
markStopped appends a small label to the bottom of the bubble so the user knows the response didn’t finish:
function markStopped(bubble)
if (!bubble) return;
bubble.classList.add('stopped');
const label = document.createElement('span');
label.className = 'stopped-label';
label.textContent = 'response stopped';
bubble.appendChild(label);
The null check on bubble 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.
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.
To make that work, we need to hold onto the question when the stream starts:
let lastQuestion = ''; function startStream(question, answer) lastQuestion = question; // rest of setup...
Then, when the retry attempt runs, we reset everything and start fresh:
function retryStream()
if (currentMsgEl && currentMsgEl.parentNode)
currentMsgEl.remove();
charIndex = 0;
userScrolled = false;
pending = '';
rafQueued = false;
isStreaming = true;
retryBtn.style.display = 'none';
stopBtn.style.display = '';
setStatus('Streaming...', 'streaming');
chat.addEventListener('scroll', onScroll, passive: true );
setTimeout(() =>
initAIMsg();
tick(lastAnswer);
, 200);
The reset is critical. Every piece of state needs to go back to its initial value, just like a brand new stream.
Note: We remove the entire message row (currentMsgEl), not just the bubble. If only the bubble is removed, the layout wrapper and avatar remain persistent and break the structure.
There’s one more edge case that’s 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.
Here’s what to do: stop the current stream before starting a new one.
function startStream(question, answer)
if (isStreaming)
clearTimeout(streamTimer);
isStreaming = false;
pending = '';
rafQueued = false;
if (cursorEl && cursorEl.parentNode) cursorEl.remove();
chat.removeEventListener('scroll', onScroll);
// now reset and start fresh
charIndex = 0;
userScrolled = false;
isStreaming = true;
lastQuestion = question;
// ...
Here, we inline the cleanup rather than calling stopStream directly because stopStream also calls markStopped and resets the buttons. The next demo has all three behaviors wired up. You can start a stream, hit “Stop” mid-stream, and the cursor disappears, the “response stopped” label appears, and a “Retry” buttons displayed.

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 those with motion sensitivities.
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.
Screen readers don’t 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’t hear anything.
The fix is aria-live. It tells the browser to watch a container and announce updates as they happen, without the user needing to move focus.
<div id="chat" role="log" aria-live="polite" aria-atomic="false" aria-label="Chat messages" ></div>
role="log" tells assistive tech this is a stream of updates, like a running transcript. Some tools handle this automatically, but it’s safer to be explicit so behavior stays consistent.aria-atomic="false" 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.aria-live="polite" queues updates instead of interrupting. Use assertive only for things that really need immediate attention, like errors.Earlier, we inserted a “Response Stopped” label to the message when the stream stops mid-stream. Visually, that’s enough. But for a screen reader, that change needs to be announced.
Since the message is inside a live region with aria-live="polite", the label will be automatically announced as new content when it’s added to the DOM. The live region already handles the announcement, so no additional ARIA is needed on the label itself.
The Retry button that appears next also needs context. If a screen reader simply says “Retry, button,” it’s not clear what action that refers to. You can fix that by adding an aria-label that includes the original question:
retryBtn.setAttribute( 'aria-label', `Retry: $lastQuestion.slice(0, 60)` );
What you can do here is to set this label when the button appears, not on page load:
retryBtn.style.display = 'inline-block'; retryBtn.setAttribute( 'aria-label', `Retry: $lastQuestion.slice(0, 60)` );
We also call retryBtn.focus() after stopping. That way, keyboard users don’t have to Tab around with the keyboard to find the next action.
Testing with assistive technology: Don’t 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’s exposed in the accessibility tree, but they can’t tell you how the content sounds. A real screen reader will reveal whether the announcement is happening at the right time and in the right way.
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, Tab + Enter is the only way to cancel a running stream.
Using display: none is fine for hiding buttons; it removes them from the tab order. The problem is using things like opacity: 0 or visibility: hidden. Those hide elements visually, but they can still receive focus, so users end up tabbing onto something they can’t see.
Use :focus-visible so the focus ring shows up for keyboard navigation, but not for mouse clicks:
btn:focus-visible outline: 2px solid #1d9e75; outline-offset: 2px;
The cursor inside the message should have aria-hidden="true". It’s just visual. Without that, some screen readers try to read it as text, which gets distracting.
The typewriter effect we see in practically every AI interface produces constant motion. As we’ve already discussed, certain amounts of motion can be disabling. Thankfully, browsers expose prefers-reduced-motion, which detects a user’s motion preferences at the operating system level.
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.
const reducedMotion = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
if (reducedMotion) initAIMsg(); for (const char of text) appendChar(char); if (cursorEl && cursorEl.parentNode) cursorEl.remove(); done(); return; tick(text); // normal animation
In CSS, the cursor blink also needs to stop. Despite being a minor detail, a blinking cursor element counts as flashing content.
@media (prefers-reduced-motion: reduce) .cursor animation: none; opacity: 1;
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.

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.
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’t handled well, the UI feels unstable and hard to use.
The patterns in this article fix that by:
You don’t need all of these every time. But when streaming is involved, these are the places things usually go wrong.
fetch. Useful when you need more control than SSE.