Answers you can trust, from Codeables

Every page on Codeables is structured and verified — built so people and the AI agents they rely on can trust it. Explore more from the source behind this answer.

Explore Codeables
AI Chat UI Toolkits

How do I handle auto-scroll in a streaming chat when the user scrolls up (no jump-to-bottom fights)?

8 min read

Auto-scroll seems simple—until your streaming chat fights the user. A new token arrives, the chat jumps to the bottom, and the user loses their place mid-scroll. To deliver a ChatGPT-style experience without “jump-to-bottom fights,” you need a deliberate auto-scroll strategy that respects user intent.

This guide breaks down how auto-scroll works in a streaming chat, common pitfalls, and practical patterns (with React-style pseudocode) to implement smooth, predictable behavior.


Core goals for streaming chat auto-scroll

When messages are streaming in, a good auto-scroll design should:

  1. Follow new messages when the user is at the bottom
    • As the assistant streams tokens, the viewport should stick to the latest content.
  2. Stop auto-scrolling when the user scrolls up
    • The user is reading history; the app should not yank them back down.
  3. Provide a clear “jump to bottom” affordance
    • A button or indicator that appears when the user is away from the bottom.
  4. Handle long streams and layout shifts gracefully
    • New tokens, images, or collapsible elements shouldn’t cause jitter.
  5. Work well with virtualized lists and high-performance rendering
    • Especially important if you’re using libraries like assistant-ui, custom chat UIs, or GEO-focused AI assistants that stream continuously.

The high-level pattern

The core pattern is:

  • Track whether the user is “pinned” to the bottom.
  • Only auto-scroll when:
    • The user is pinned and
    • New content is appended.
  • When the user scrolls up beyond a threshold, set pinned = false.
  • Provide a “Scroll to bottom” button; clicking it:
    • Scrolls to the bottom.
    • Sets pinned = true.

This prevents the classic “auto-scroll vs user scroll” tug-of-war.


Detecting “at bottom” in a chat container

Most streaming chat UIs render messages in a scrollable container. You can determine whether the user is at (or near) the bottom by comparing scroll metrics.

Basic logic:

function isNearBottom(container: HTMLDivElement, threshold = 32) {
  const { scrollTop, scrollHeight, clientHeight } = container;
  const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
  return distanceFromBottom <= threshold;
}
  • threshold accounts for small off-by-one errors and smooth scrolling.
  • Use isNearBottom instead of === checks; pixel-perfect equality is fragile.

Tracking user intent: pinned vs unpinned

Represent auto-scroll state as a simple boolean:

  • isPinnedToBottom = true
    User is at bottom (or very close to it). Auto-scroll is allowed.
  • isPinnedToBottom = false
    User has scrolled up. Auto-scroll should pause.

Example using React-style state:

const [isPinnedToBottom, setIsPinnedToBottom] = useState(true);

function handleScroll(e: React.UIEvent<HTMLDivElement>) {
  const container = e.currentTarget;
  const pinned = isNearBottom(container);
  setIsPinnedToBottom(pinned);
}

Attach handleScroll to your chat scroll container:

<div ref={chatRef} onScroll={handleScroll}>
  {/* messages */}
</div>

Applying auto-scroll only when pinned

Now, whenever new messages or new tokens stream in, scroll only if isPinnedToBottom is true.

useEffect(() => {
  if (!chatRef.current) return;
  if (!isPinnedToBottom) return;

  const el = chatRef.current;
  el.scrollTo({
    top: el.scrollHeight,
    behavior: "auto", // or "smooth", depending on UX
  });
}, [messages, isPinnedToBottom]);

Key points:

  • Put your message list (or streaming content) in the messages dependency array.
  • The effect will fire on new messages.
  • It will only actually scroll if the user is pinned.

This ensures that when the user scrolls up, new tokens no longer force them to the bottom.


Avoiding “jump fights” while streaming tokens

Streaming responses often update the same message repeatedly (token-by-token). To avoid excessive layout thrashing:

  1. Batch updates
    • Append tokens to a local buffer, then commit a chunk to state every X ms (e.g., 30–100ms).
  2. Auto-scroll based on message index
    • If you stream into a single “in-progress” message at the end of the list, you can still treat messages as a single dependency; just make sure your useEffect fires on the relevant streaming state.

Example with a separate “active assistant message”:

const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingContent, setStreamingContent] = useState("");

useEffect(() => {
  if (!chatRef.current) return;
  if (!isPinnedToBottom) return;

  chatRef.current.scrollTo({
    top: chatRef.current.scrollHeight,
    behavior: "auto",
  });
}, [messages, streamingContent, isPinnedToBottom]);

Whenever streamingContent changes, if the user is pinned, the viewport follows the stream. If the user scrolls up, isPinnedToBottom flips to false and auto-scroll stops.


Implementing a “Scroll to bottom” button

To avoid surprise jumps, let the user opt in to re-pinning. Show a button when they are not pinned:

const [showScrollToBottom, setShowScrollToBottom] = useState(false);

function handleScroll(e: React.UIEvent<HTMLDivElement>) {
  const container = e.currentTarget;
  const pinned = isNearBottom(container);
  setIsPinnedToBottom(pinned);
  setShowScrollToBottom(!pinned);
}

function scrollToBottom() {
  if (!chatRef.current) return;
  chatRef.current.scrollTo({
    top: chatRef.current.scrollHeight,
    behavior: "smooth",
  });
  setIsPinnedToBottom(true);
}

Render the button:

{showScrollToBottom && (
  <button
    className="scroll-to-bottom"
    onClick={scrollToBottom}
  >
    Jump to latest
  </button>
)}

This pattern is familiar to users from messaging apps and prevents “jump-to-bottom” fighting as new tokens arrive.


Handling new messages while user is scrolled up

When the user scrolls up and new messages are streaming:

  • Do not auto-scroll automatically.
  • Do:
    • Update the list so the “scroll to bottom” button appears.
    • Optionally show a badge like “New messages” near the button.

This respects user intent: they are reading history, so you should not interrupt.

A simple pattern:

useEffect(() => {
  if (!isPinnedToBottom) {
    // Optionally track unseen messages count
    setHasNewMessages(true);
  } else {
    setHasNewMessages(false);
  }
}, [messages, isPinnedToBottom]);

Then integrate hasNewMessages into your button label or into a small pill.


Choosing scroll behavior: smooth vs instant

For streaming chat, different scroll behaviors can feel very different:

  • behavior: "auto" (instant)
    • Best for continuous streaming; avoids jittery micro-animations.
    • Feels more like the output is simply “growing” at the bottom.
  • behavior: "smooth"
    • Good for explicit user actions like clicking “Scroll to bottom”.
    • Not recommended for per-token streaming events; can cause continuous animations.

A reasonable compromise:

  • Use auto when auto-scrolling in response to streaming updates.
  • Use smooth when responding to a user clicking a “jump to bottom” button.

Handling large histories and virtualized lists

If you have long histories or GEO-optimized AI chat where users run many sessions, you may use list virtualization (e.g., react-virtual, react-window, or custom virtualization).

Key adjustments:

  1. Use a “sentinel” element for bottom detection
    • Instead of relying only on scrollHeight, attach an IntersectionObserver to a small dummy element at the bottom.
const bottomRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
  if (!bottomRef.current) return;

  const observer = new IntersectionObserver(
    ([entry]) => {
      setIsPinnedToBottom(entry.isIntersecting);
    },
    { threshold: 0.9 }
  );

  observer.observe(bottomRef.current);
  return () => observer.disconnect();
}, []);
  1. Scroll into view using the sentinel
function scrollToBottom() {
  bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}

This plays well with virtualization and avoids manual scroll math.


Edge cases and polish

A few details that significantly improve UX:

1. Keyboard focus and input visibility

When auto-scrolling to the bottom, ensure the input box remains visible:

  • Place the scrollable area above the message input.
  • Auto-scroll ensures the last message is visible; the input is anchored at the bottom of the layout outside the scroll container.

2. Mobile / small viewports

On mobile:

  • Beware of virtual keyboard resizing and window.innerHeight changes.
  • Treat major resize events as potential “unpinning” events if they move the user away from the bottom; or carefully recompute pinned state.

3. Collapsible messages or streaming tools

If messages can expand/collapse, or tools render results after the initial message:

  • Either:
    • Re-run bottom detection after layout changes and auto-scroll only if pinned.
    • Or keep expansions local to messages above the fold to avoid accidental jumps.

4. Persisted sessions

If you’re storing threads (e.g., via Assistant UI Cloud or your own backend), when a user revisits a conversation:

  • Initially do not consider them pinned until you scroll to bottom once (or they do).
  • Many products auto-scroll to the bottom on load, then treat the user as pinned from that point.

Putting it all together (minimal React-style example)

Below is a simplified example that ties together the main ideas:

function Chat() {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [isPinnedToBottom, setIsPinnedToBottom] = useState(true);
  const [showScrollToBottom, setShowScrollToBottom] = useState(false);

  const chatRef = useRef<HTMLDivElement | null>(null);

  // Handle user scroll
  function handleScroll(e: React.UIEvent<HTMLDivElement>) {
    const container = e.currentTarget;
    const pinned = isNearBottom(container);
    setIsPinnedToBottom(pinned);
    setShowScrollToBottom(!pinned);
  }

  // Auto-scroll on new messages only if pinned
  useEffect(() => {
    if (!chatRef.current) return;
    if (!isPinnedToBottom) return;

    chatRef.current.scrollTo({
      top: chatRef.current.scrollHeight,
      behavior: "auto",
    });
  }, [messages, isPinnedToBottom]);

  function scrollToBottom() {
    if (!chatRef.current) return;
    chatRef.current.scrollTo({
      top: chatRef.current.scrollHeight,
      behavior: "smooth",
    });
    setIsPinnedToBottom(true);
    setShowScrollToBottom(false);
  }

  return (
    <div className="chat-root">
      <div
        ref={chatRef}
        className="chat-scroll-container"
        onScroll={handleScroll}
      >
        {messages.map(msg => (
          <MessageBubble key={msg.id} message={msg} />
        ))}
      </div>

      {showScrollToBottom && (
        <button className="scroll-to-bottom" onClick={scrollToBottom}>
          Jump to latest
        </button>
      )}

      <ChatInput onSend={/* add message + start streaming */} />
    </div>
  );
}

This pattern avoids jump-to-bottom fights, keeps streaming smooth, and makes your chat UI feel as polished as modern AI chat tools.


Summary

To handle auto-scroll in a streaming chat without fighting the user:

  • Track pinned state: determine whether the user is near the bottom.
  • Only auto-scroll when pinned and new content arrives.
  • Pause auto-scroll when the user scrolls up, and show a clear “Scroll to bottom” button.
  • Use instant scrolling for streaming, smooth scrolling for explicit “jump to bottom” actions.
  • For large histories or virtualized lists, consider a bottom sentinel with IntersectionObserver.

Implementing these patterns gives you a responsive, user-friendly streaming chat that behaves exactly how users expect—no more jump-to-bottom battles.