Search IOCombats

Search challenges, guides, questions and articles

Streaming Chat UI in React: Auto-Scroll and Screen Readers Done Right
aireactstreamingaccessibilityresizeobserververcel ai sdk

Streaming Chat UI in React: Auto-Scroll and Screen Readers Done Right

By Ghazi Khan | Nov 2, 2026 - 10 min read

You ship a streaming chat UI in React, the answer starts flowing in, and the user scrolls up to re-read the question. Your useEffect drags them straight back down on the next token. Then a screen reader user tries it and hears the answer in broken fragments, word by word, while they are trying to type. Neither bug shows up in a quick demo, and both make the feature feel broken.

This post builds the two pieces most streaming chat tutorials skip: auto-scroll that respects the user, and screen reader announcements that describe what is happening instead of reading every token. Rendering speed (memoized markdown blocks, useTransition) is covered in Building a Streaming AI Chat UI in React. Here we care about what happens around the text.

The concept: the user owns the scroll position

Think of tail -f in a terminal. It follows new lines while you are at the bottom. Scroll up and it stops following, so you can read. Come back down and it follows again. That is the whole behaviour, and it maps to a single boolean: stick.

  • stick is true when the user is at (or very near) the bottom.
  • It becomes false the moment the user scrolls up.
  • It becomes true again when they scroll back to the bottom, click "Jump to latest", or send a new message.
  • When content grows and stick is true, scroll to the bottom. Otherwise, do nothing.

The React analogy is a controlled versus uncontrolled input. Once the user touches the scroll position, it is their value, not yours. Your code only takes control back when they hand it over.

Diagram
100%
stateDiagram-v2 [*] --> Following Following --> Reading: user scrolls up Reading --> Following: user reaches bottom Reading --> Following: clicks Jump to latest Reading --> Following: sends a new message Following --> Following: content grows, scroll to bottom Reading --> Reading: content grows, do nothing
visualized byIOCombats

Two details make this work in practice.

React to size, not to state. A useEffect that runs when messages changes fires after React commits the text, but markdown rendering, syntax highlighting and images can change the height later. A ResizeObserver on the inner content element fires whenever its size actually changes. Resize observations are delivered after layout and before paint in the same rendering update, so your scroll lands before the user sees the frame.

Detect direction, not just distance. If you unstick whenever the distance from the bottom is larger than a threshold, a smooth "Jump to latest" scroll unsticks itself halfway through its own animation. Only an upward scroll by the user should turn stick off.

For screen readers, the rule is similar: announce events, not tokens. A chat history is a natural role="log", and MDN documents that log has an implicit aria-live="polite". If you stream text into it, every small DOM change becomes a candidate announcement. So keep role="log" for structure, set aria-live="off" on it, and announce short lifecycle messages ("Generating answer", "Answer complete", "Generation stopped") through a separate, visually hidden role="status" element that is in the DOM from the first render.

Build it: a streaming chat UI in React that stays out of the way

We will use Next.js with the Vercel AI SDK (ai and @ai-sdk/react). Install with npm i ai @ai-sdk/react. The model string below follows the current AI SDK docs and routes through the Vercel AI Gateway, so set AI_GATEWAY_API_KEY, or swap in any provider package you already use.

Step 1: the route handler

// app/api/chat/route.ts
import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  streamText,
  toUIMessageStream,
  type UIMessage,
} from 'ai';

export const maxDuration = 30;

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: 'anthropic/claude-sonnet-5.5',
    instructions: 'You are a helpful assistant. Answer in short paragraphs.',
    messages: await convertToModelMessages(messages),
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  });
}

Nothing here is specific to scrolling. It streams UI message parts to the client, which useChat knows how to read. Last week's post covered the server side in depth: Streaming LLM Responses in Node.js.

Step 2: a stick-to-bottom hook

// app/use-stick-to-bottom.ts
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
const THRESHOLD = 40; // px of slack; tune for your layout
export function useStickToBottom() {
  const scrollRef = useRef<HTMLDivElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);
  const stickRef = useRef(true); // read by observers, never stale
  const [isAtBottom, setIsAtBottom] = useState(true); // drives the button only

  const setStick = useCallback((value: boolean) => {
    stickRef.current = value;
    setIsAtBottom(value);
  }, []);
  useEffect(() => {
    const scroller = scrollRef.current;
    const content = contentRef.current;
    if (!scroller || !content) return;
    let lastTop = scroller.scrollTop;

    const onScroll = () => {
      const top = scroller.scrollTop;
      const distance = scroller.scrollHeight - top - scroller.clientHeight;
      if (distance <= THRESHOLD) setStick(true);
      else if (top < lastTop) setStick(false); // only an upward scroll unsticks
      lastTop = top;
    };
    const observer = new ResizeObserver(() => {
      if (stickRef.current) scroller.scrollTop = scroller.scrollHeight;
    });
    scroller.addEventListener('scroll', onScroll, { passive: true });
    observer.observe(content);
    return () => {
      scroller.removeEventListener('scroll', onScroll);
      observer.disconnect();
    };
  }, [setStick]);
  return { scrollRef, contentRef, isAtBottom, setStick };
}

The ref and the state hold the same value for different readers. The ResizeObserver callback is created once, so it reads stickRef to avoid a stale closure. The state only exists to show or hide the "Jump to latest" button. React skips the re-render when setIsAtBottom receives the same value, so calling it on every scroll event is cheap.

Here is what happens in a single frame while the answer streams:

Diagram
100%
sequenceDiagram participant S as Stream (useChat) participant R as React commit participant L as Layout participant O as ResizeObserver participant P as Paint S->>R: new text part (throttled) R->>L: DOM updated, content taller L->>O: size changed O->>O: stick is true, set scrollTop O->>P: frame painted at the bottom
visualized byIOCombats

Step 3: jump to the bottom, with reduced motion in mind

// app/scroll-to-bottom.ts
export function scrollToBottom(
  scroller: HTMLElement | null,
  setStick: (value: boolean) => void,
  smooth = false,
) {
  if (!scroller) return;
  setStick(true);
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  scroller.scrollTo({
    top: scroller.scrollHeight,
    behavior: smooth && !reduce ? 'smooth' : 'auto',
  });
}

Sticking is set before scrolling, so the observer keeps following even if more text lands mid-animation. Because the scroll moves down, the direction check in the hook never unsticks it.

Step 4: announcements that describe events

// app/use-chat-announcer.ts
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';

type Status = 'submitted' | 'streaming' | 'ready' | 'error';

export function useChatAnnouncer(status: Status) {
  const [text, setText] = useState('');
  const prev = useRef<Status>(status);
  const stopped = useRef(false);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);

  useEffect(() => {
    const was = prev.current;
    prev.current = status;
    let next = '';
    if (status === 'submitted') {
      stopped.current = false;
      next = 'Generating answer';
    }
    if (status === 'error') next = 'The answer failed. You can try again.';
    if (status === 'ready' && was !== 'ready') {
      next = stopped.current ? 'Generation stopped' : 'Answer complete';
    }
    if (!next) return;
    clearTimeout(timer.current);
    setText(''); // clear first so a repeated message still counts as a change
    timer.current = setTimeout(() => setText(next), 100);
  }, [status]);

  useEffect(() => () => clearTimeout(timer.current), []);
  const markStopped = useCallback(() => {
    stopped.current = true;
  }, []);
  return { text, markStopped };
}

useChat exposes four statuses: submitted, streaming, ready and error. Both a finished answer and a stopped one end in ready, so the hook keeps a stopped flag that the Stop button sets. The short clear-then-set delay is a common workaround, not a spec rule: screen readers react to changes, and setting the same string twice is not a change.

Step 5: wire it together

// app/chat.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useState } from 'react';
import { useStickToBottom } from './use-stick-to-bottom';
import { useChatAnnouncer } from './use-chat-announcer';
import { scrollToBottom } from './scroll-to-bottom';
export default function Chat() {
  const { messages, sendMessage, status, stop } = useChat({
    transport: new DefaultChatTransport({ api: '/api/chat' }),
    throttle: 50, // batch UI updates instead of one render per chunk
  });
  const { scrollRef, contentRef, isAtBottom, setStick } = useStickToBottom();
  const { text: announcement, markStopped } = useChatAnnouncer(status);
  const [input, setInput] = useState('');
  const busy = status === 'submitted' || status === 'streaming';
  const jump = (smooth = false) =>
    scrollToBottom(scrollRef.current, setStick, smooth);
  return (
    <div className='chat'>
      <div
        ref={scrollRef}
        className='scroller'
        role='log'
        aria-label='Conversation'
        aria-live='off'>
        <div ref={contentRef}>
          {messages.map((m) => (
            <article
              key={m.id}
              aria-label={m.role === 'user' ? 'You' : 'Assistant'}>
              {m.parts.map((p, i) =>
                p.type === 'text' ? <p key={i}>{p.text}</p> : null,
              )}
            </article>
          ))}
        </div>
      </div>
      {!isAtBottom && (
        <button onClick={() => jump(true)}>Jump to latest</button>
      )}
      <div role='status' className='sr-only'>
        {announcement}
      </div>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          if (!input.trim() || busy) return;
          sendMessage({ text: input });
          setInput('');
          jump();
        }}>
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          aria-label='Message'
        />
        {busy ? (
          <button
            type='button'
            onClick={() => {
              markStopped();
              stop();
            }}>
            Stop
          </button>
        ) : (
          <button type='submit'>Send</button>
        )}
      </form>
    </div>
  );
}

Give .scroller a fixed height with overflow-y: auto, and add overscroll-behavior: contain so reaching the end of the chat does not scroll the page behind it. Leave overflow-anchor at its default of auto. Scroll anchoring keeps the reader's place when content above them changes height, for example when a code block above the viewport finishes highlighting. It is now Baseline: Safari added it in Safari 27 (September 2026), so users on older Safari versions can still see small jumps.

The table below sums up which tool handles which job.

JobToolWhy not the obvious alternative
Know when content grewResizeObserver on the inner elementA messages effect misses late layout changes
Know if the user left the bottomScroll event plus direction checkDistance alone breaks smooth scrolling
Keep place when content above changesoverflow-anchor: auto (default)Manual offset maths is fragile
Tell screen readers what happenedHidden role="status"A live role="log" reads token fragments
Limit render frequencythrottle in useChatOne render per chunk wastes main-thread time

Common mistakes and their fixes

  1. Scrolling in an effect on every message change. It fights users who scroll up and misses late height changes. Fix: a stick flag plus a ResizeObserver on the content, as in Step 2.
  2. Unsticking on distance alone. The "Jump to latest" smooth scroll unsticks itself mid-animation and stops following. Fix: only an upward scroll by the user turns stick off.
  3. Letting the history announce itself. role="log" is implicitly polite, so streamed text gets read in fragments. Fix: aria-live="off" on the log and short lifecycle messages in a separate role="status".
  4. Mounting the status element only when needed. Live regions inserted together with their text may not be announced. Fix: render the empty role="status" from the first render and only change its text.
  5. Disabling the input while streaming. A disabled input cannot hold focus, so keyboard users lose their place. Fix: keep the input enabled and block submission in the handler while busy, as in Step 5.

Interview angle

Streaming UI questions in AI-focused frontend interviews often start with "build a chat that streams" and then probe the edges: what happens when the user scrolls up, how a smooth scroll interacts with your detection logic, and how a screen reader experiences the answer. Naming ResizeObserver, the stick flag with a direction check, and role="status" for lifecycle announcements is a strong answer. Practise with the streaming UI interview questions.

What's next

On Thursday, the case study How GitHub Copilot shows inline code completions looks at a different streaming UI: suggestions that appear inside the editor as you type. Next week we go deeper into the Vercel AI SDK itself: streamText, useChat and providers, end to end.

Conclusion

A streaming chat UI in React is not done when tokens appear on screen. Follow the answer only while the user is at the bottom, react to size changes instead of state changes, unstick only on an upward scroll, and leave scroll anchoring on. For screen readers, keep the log quiet and announce events, not tokens. Each fix is a few lines of code, and together they are the difference between a demo and a feature people can actually use.

Advertisement

Ready to practice?

Test your skills with our interactive UI challenges and build your portfolio.

Start Coding Challenge