Designing Stable Interfaces For Streaming Content — Smashing Magazine
Skip to main content Start reading the article Jump to list of all articles Jump to all topics19 min readUI, Coding, UXShare on Twitter, LinkedInAbout The AuthorJoas is a machine learning (ML) and artificial intelligence (AI) enthusiast passionate about using these technologies to solve real-world problems. He believes … More about Joas ↬Email NewsletterYour (smashing) email Weekly tips on front-end & UX.Trusted by 182,000+ folks. See User Testing Live Celebrating 10 million developers Deep Dive On Accessibility Testing with Manuel Matuzović Live UX Training — Smart Interface Design Patterns with Vitaly Friedman The Modern UX Practitioner with Paul Boag Custom Web Forms for Angular, React, & Vue. Your backend. Cascading Style Systems: Resilient & Maintainable CSS with Miriam SuzanneStreaming 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.What Does A Streaming UI Actually Look Like?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.Example 1: Streaming AI Chat ResponsesThis is the most familiar case. You click Stream, and the message starts growing token by token, just like a typical AI chat interface.Open in CodeSandbox. (Large preview)Here’s what I want you to try:Click the Stream button.Try scrolling upwards while the message is streaming.Increase the speed (to something like 10ms).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.Example 2: Live Processing In A Log ViewerThis 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:Open in CodeSandbox. (Large preview)Again, here is what I want you to try:Click the Start button.Allow the logs to stream past the container’s height.Scroll up to the beginning.Stop the stream and disable the “tail” option.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.Example 3: Dashboard Displaying Real-Time MetricsIn this case, the UI updates in place:Numbers change,Charts shift,Values refresh continuously.Open in CodeSandbox. (Large preview)There is no scroll tension this time, but a different issue shows up. That’s what we’ll get into next.Why The UI Feels Unstable And How To Fix ItIf 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.Ensure Predictable Scroll BehaviorThis is the goal:Enable auto-scrolling when detecting that the user is at the bottom of the stream.Stop auto-scrolling when the user has scrolled upwards.Resume auto-scrolling if the user scrolls back to the bottom of the stream.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; }); That 60px 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’t actually scroll.Now let’s make sure that we enable auto-scrolling only when the user’s scroll position is equal to the stream’s scroll height, i.e., the user is at the bottom of the stream:function autoScroll() { if (!userScrolled) { chatEl.scrollTop = chatEl.scrollHeight; } } One small thing that’s easy to miss: we need to reset userScrolled once a new stream begins. Otherwise, one scroll from a previous message can silently disable auto-scroll for the next one.Solidify Layout StabilityWe 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’s shifting: it’s not the page layout in a broad sense, it’s the content directly below the chat bubble.There’s also a subtler artifact worth calling out before we look at the code: cursor flicker. Because we’re wiping innerHTML 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.At normal speed, it’s easy to miss, but slow the slider down to around 30ms, and you’ll see a faint but persistent flicker at the end of the text. Once we fix the rebuild pattern, the flicker disappears entirely.That rebuild pattern is right here; this is what runs on every single incoming character:bubble.innerHTML = '';
fullText.split('\n').forEach(line => { const p = document.createElement('p'); p.textContent = line || '\u00A0'; bubble.appendChild(p); });
bubble.appendChild(cursorEl); This works, but it’s expensive. Every update wipes the DOM and rebuilds it, forcing layout recalculation each time.Now we write directly into a live node:let currentP = null;
function initBubble(bubble, cursor) { currentP = document.createElement('p'); currentP.appendChild(document.createTextNode('')); bubble.insertBefore(currentP, cursor); } 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.Then, for each character that arrives:function appendChar(char, bubble, cursor) { if (char === '\n') { currentP = document.createElement('p'); currentP.appendChild(document.createTextNode('')); bubble.insertBefore(currentP, cursor); } else { currentP.firstChild.textContent += char; } } For a regular character, we extend the text node by one character. The browser doesn’t need to recalculate the layout for that; the text grew, but nothing moved. For a newline, we create a fresh paragraph and move currentP forward. Layout recalculates once for that new paragraph, and that’s it.Render FrequencyThis one is most visible in the first example, the chat UI. Even with scrolling and a layout fixed, we’re still writing to the DOM on every single incoming character.When the stream is moving fast, you end up hammering the DOM with updates that don’t actually matter. The fix is straightforward: hold the incoming text in a buffer instead of writing it out immediately. Once you’ve collected enough, write it all to the DOM in one go; that’s what a flush is.To pull this off, we keep a simple buffer and make sure we only schedule a single update at a time. When it
