The loading-state playbook assumes a shape: you wait, then content appears. Delay the indicator, show a skeleton, swap it for the real thing.
A streaming interface has no such moment. The answer starts before it is finished, arrives in pieces for anywhere between two and sixty seconds, and changes the layout continuously while the user is reading it. The skeleton has nothing to stand in for, and the progress bar has nothing honest to report.
What replaces it is not a new indicator. It is a much stricter policy about what moves.
Three waits, not one
The single most common mistake is treating the whole interaction as one loading state. There are three, and they want different things:
Thinking: the request is out and nothing has come back. Genuinely indeterminate: it can resolve in 300ms or hang for thirty seconds.
Streaming: tokens are arriving. Something is happening and the user can see it happening. This state needs almost no motion at all, because the content is already moving.
Working: the system is running tools, searching, calling something. Discrete, nameable, and the only one of the three with real structure worth showing.
One indicator across all three is how you end up with a spinner still turning next to a paragraph of finished text.
Thinking: the only place a loop belongs
This is the one state that earns a looping animation, because it is the one state with nothing else to look at.
Keep it small, keep it in the position the answer will occupy, and keep it slow: a 1.5 to 2 second pulse, low contrast. A fast, bright indicator makes the wait feel long by drawing attention to it, which is the same rule that governs skeleton shimmers.
Two things matter more than the animation itself:
It must die on the first token, instantly. A thinking indicator that fades out over 300ms while text is already rendering puts two states on screen at once, and reads as two systems disagreeing about what is happening. Cut it. There is no transition to design here.
It must not restart. In a framework that re-renders on every chunk, a CSS animation on a remounting node restarts from frame zero each time, producing a “pulse” that never completes a cycle and reads as a stutter. Keep the indicator’s element stable across renders, or drive it from a parent that is not re-rendering.
If thinking runs past about ten seconds, stop animating and start talking: replace the pulse with a specific label. “Searching documentation” is a better twelve seconds than any indicator, and it converts an unbounded wait into a described one.
Streaming: do not animate the text
This is the rule that does the most work, and the one most often broken: while tokens are arriving, the text itself gets no animation.
Fading in each token, sliding each line, staggering each word: all of it fights the reader. Three reasons, in order of how much they hurt:
- The eye is already tracking. Reading is the animation. A fade competes with the saccade the reader is making toward the word.
- It jitters. Tokens do not arrive at even intervals. They arrive in bursts, with network gaps. An animation per token turns an uneven stream into visible stutter, where plain text would have absorbed the unevenness invisibly.
- It queues. A 200ms fade per token with tokens landing every 30ms builds a backlog that runs progressively further behind the model. The stagger arithmetic applies exactly as it does to lists, except here the input rate is set by a server you do not control.
The exception that proves the rule: a single fade on the first chunk, as the answer replaces the thinking state, is fine. It happens once, and it marks a real transition. Everything after that is plain text.
What to animate instead: the caret
One element, one animation, doing the whole job the spinner was doing, sitting at the end of the stream and blinking.
.caret {
display: inline-block;
width: 0.5em;
height: 1em;
background: currentColor;
vertical-align: -0.15em;
animation: caret-blink 1s steps(2, start) infinite;
}
@keyframes caret-blink {
to { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.caret { animation: none; }
}
It communicates “still going” without touching the text, it marks where the next word will land, and it disappears the instant the stream ends. steps(2, start) is deliberate. A smoothly fading cursor reads as decoration, a hard blink reads as a terminal.
The two layout traps
Do not transition the height. The container grows every few frames as text arrives. Putting a transition on that height means animating a layout property continuously, and the visible result is a container permanently lagging behind its own contents, with the last line clipped for the duration of the answer. Let the growth be instantaneous. Each individual step is small enough to sit below perception anyway.
Do not fight the scroll. Pinning to the bottom while streaming is correct. Continuing to pin after the user has scrolled up to re-read something is the single most complained-about behaviour in streaming interfaces. Release the pin the moment they scroll away, and do not re-pin without an explicit action:
let pinned = true;
el.addEventListener('scroll', () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
pinned = distance < 40; // a tolerance, not zero: sub-pixel scroll positions
});
function onChunk() {
if (pinned) el.scrollTop = el.scrollHeight; // instant, never smooth
}
scroll-behavior: smooth is wrong here for the same reason a per-token fade is: it has a duration, and the tokens do not wait for it. Smooth scrolling on a stream produces a viewport that is always travelling toward a position that has already moved.
Working: steps are a list, not a spinner
When the system is running tools, you have something rare in this whole interaction: real, nameable progress. Show it as a list that accumulates, not as a single indicator that changes label.
- Completed steps stay visible, in place, with a completion mark. That mark is the one genuine micro-interaction here: 150–200ms, one element, done.
- The transition worth animating is between steps, not within them. Collapsing a finished step’s detail is fine; animating the arrival of its text is the token problem again, one level up.
- Keep the panel’s height stable as steps collapse and expand, or the message below it walks up and down the screen while the user is reading it.
Stopping is a motion problem
The stop button is where most streaming interfaces give themselves away.
Stopping must be instantaneous. Any easing, any fade, any 200ms settle reads as the model still running for 200ms after you told it not to. Cut the caret, cut the indicator, in the same frame as the click.
The partial answer must not move. It does not fade, collapse, grey out, or slide anywhere. Whatever was on screen when the user hit stop stays exactly where it is. They stopped it because they wanted to read what was already there. The only thing that changes is the state around it.
The same applies to errors mid-stream. The text that arrived is still true; put the error underneath it and leave everything above untouched.
Reduced motion
Two things to substitute, both of them the exact repetitive movement prefers-reduced-motion exists to reduce:
- The thinking pulse becomes a static label. “Thinking…” carries the same information with no movement, which is the whole point: reduced motion means less movement, not less information.
- The caret stops blinking and stays solid. It still marks position.
The summary
| State | Animate | Leave completely still |
|---|---|---|
| Thinking | one slow indeterminate loop | everything else |
| Streaming | the caret, and nothing else | the text, the height, the scroll |
| Working | the step transition, the completion mark | the finished steps |
| Stopped or errored | nothing, cut in one frame | the partial answer |
The through-line is that a streaming interface is the rare case where the content is already in motion. Every animation you add is competing with the thing the user is actually trying to do, which is read. Most of the craft here is in what you refuse to animate.