How should auto-scroll behave while an answer streams, without fighting the user?
Advertisement
🧩 Scenario
Concept Walkthrough
The rule in one line
Follow the stream only if the user is already following it. That is the same rule a terminal or a log viewer uses. Auto-scroll is a convenience for people watching the end of the answer, not a rule that overrides people reading something else.
Three pieces of state
- Is the user at the bottom? Compute it from the scroll container:
scrollHeight - scrollTop - clientHeightis the distance from the bottom. If it is under a small tolerance, treat it as "at bottom". - Should we stick? A boolean, updated on every scroll event from the position check. If the user scrolls up, it becomes
false. If they scroll back down to the bottom, it becomestrueagain. - Has new content arrived while not stuck? This drives the Jump to latest button.
When we scroll programmatically, the browser fires a scroll event too. That is fine: after our scroll we are at the bottom, so the check keeps stick as true.
When to scroll
Scroll when the content actually grows, not when a piece of state changes. Markdown rendering, code highlighting and images can change height after the text update, so a useEffect that watches the text can fire too early. A ResizeObserver on the inner content element fires whenever its size changes. Inside the callback: if stick is true, set scrollTop to scrollHeight.
Instant, not smooth
While following a stream, jump instantly. A smooth scroll takes time to finish, and new tokens keep arriving, so the animation keeps restarting and lags behind. Keep smooth scrolling for the button click, and skip it when the user has prefers-reduced-motion: reduce.
When sticking turns back on
- The user scrolls back to the bottom.
- The user clicks Jump to latest.
- The user sends a new message (they clearly want to see the reply).
Key Code Explained
import { useCallback, useEffect, useRef, useState } from 'react';
const BOTTOM_TOLERANCE_PX = 24; // tune for your layout
export function useStickToBottom() {
const containerRef = useRef<HTMLDivElement>(null); // the scrollable element
const contentRef = useRef<HTMLDivElement>(null); // the element that grows
const stickRef = useRef(true); // read inside observers without re-renders
const [isStuck, setIsStuck] = useState(true); // drives the Jump button
const scrollToBottom = useCallback((smooth = false) => {
const el = containerRef.current;
if (!el) return;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
el.scrollTo({ top: el.scrollHeight, behavior: smooth && !reduceMotion ? 'smooth' : 'auto' });
stickRef.current = true;
setIsStuck(true);
}, []);
useEffect(() => {
const container = containerRef.current;
const content = contentRef.current;
if (!container || !content) return;
const onScroll = () => {
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
const atBottom = distance <= BOTTOM_TOLERANCE_PX;
stickRef.current = atBottom;
setIsStuck(atBottom); // React skips the render when the value is unchanged
};
// Follow real size changes: new tokens, highlighted code, loaded images
const observer = new ResizeObserver(() => {
if (stickRef.current) {
container.scrollTop = container.scrollHeight; // instant while streaming
}
});
container.addEventListener('scroll', onScroll, { passive: true });
observer.observe(content);
return () => {
container.removeEventListener('scroll', onScroll);
observer.disconnect();
};
}, []);
return { containerRef, contentRef, isStuck, scrollToBottom };
}
// Usage
function ChatPanel({ children }: { children: React.ReactNode }) {
const { containerRef, contentRef, isStuck, scrollToBottom } = useStickToBottom();
return (
<div className='chat-panel'>
<div ref={containerRef} className='chat-scroll'>
<div ref={contentRef}>{children}</div>
</div>
{!isStuck && (
<button type='button' onClick={() => scrollToBottom(true)}>
Jump to latest
</button>
)}
</div>
);
}
stickRef holds the live value so the ResizeObserver callback never reads a stale closure, while isStuck state exists only to show or hide the button. Call scrollToBottom() from your send handler so a new question always brings the user to the reply.
Tradeoffs
| Approach | Respects the user | Handles late layout changes | Notes |
|---|---|---|---|
| scrollIntoView on every chunk | No | Partly | Simplest, causes the complaint in this scenario |
| useEffect on text + stick flag | Yes | No | Misses height changes after render |
| ResizeObserver + stick flag | Yes | Yes | Small hook, the approach shown here |
| Library (for example a stick-to-bottom hook package) | Usually | Usually | Less code, check its behaviour on your layout |
What Interviewers Actually Check
- Whether you define the behaviour before writing code
- Whether you stop auto-scrolling when the user scrolls up
- Whether you use a tolerance instead of an exact bottom check
- Whether you react to real size changes, not just state changes
- Whether you offer a way back (Jump to latest) and handle new messages
Follow-Up Questions
- How does this change if the message list is virtualized?
- How would you handle loading older messages at the top without the view jumping?
- Should the Jump button show a count of new messages or just an arrow? Why?
- How would you test this behaviour with Playwright?
- How does the hook behave on mobile when the on-screen keyboard opens?
Common Candidate Mistakes
- Treating auto-scroll as always on, which makes reading earlier content impossible
- Re-enabling sticking on every new chunk, which defeats the user's scroll up
- Using smooth scrolling for every update, so the view lags behind the stream
- Observing the scroll container's own size instead of the content inside it
- Forgetting to disconnect the observer and remove listeners on unmount
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you write the distance-from-bottom calculation from memory?
- Can you explain the three moments sticking turns back on?
- Can you explain why ResizeObserver is better than a text-based effect here?
- Can you explain why instant scroll is better while streaming?
- Can you design the Jump to latest button and its visibility rule?
Summary
Auto-scroll during streaming should follow the answer only while the user is already at the bottom. Track a stick flag from the scroll position with a small tolerance, turn it off when the user scrolls up and back on when they return to the bottom, click Jump to latest, or send a new message. Follow content growth with a ResizeObserver on the inner element so late layout changes are handled, jump instantly while streaming, and keep smooth scrolling (respecting reduced motion) for the button.
Why not just call scrollIntoView on every chunk?
Because it ignores what the user is doing. If they scrolled up to read an earlier part of the answer, every new chunk yanks them back to the bottom. It also runs far more often than needed.
Should the auto-scroll be smooth?
Usually not while following the stream. Smooth scrolling takes time, and new content arrives before the animation ends, so it can feel laggy and the scroll position can end up behind. Use an instant jump while following, and smooth scrolling (respecting prefers-reduced-motion) for the Jump to latest button.
What threshold counts as "at the bottom"?
Use a small tolerance in pixels rather than an exact zero, because fractional scroll values and padding make an exact match unreliable. Tune the number for your layout.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement