One of the easiest details to get wrong in an AI chat product is "automatically scroll to the bottom." The model is streaming tokens, new text keeps appearing, and ideally the window follows the latest output. But if the user scrolls up to reread something, the interface should politely stop following. The tiny question of whether the user still wants to follow looks like two lines of code, but it can cost months of small fixes. This post is a record of the versions I went through.
The Simplest Version
My first version was the same one most people write: use a threshold. If the user is within some number of pixels from the bottom, assume they want to keep following.
function shouldAutoScroll(container: HTMLElement, threshold = 100) {
const { scrollTop, scrollHeight, clientHeight } = container
const distance = scrollHeight - scrollTop - clientHeight
return distance < threshold
}
It feels smooth at first. But as soon as the user scrolls up to read a long answer, a strange thing happens. The model is still generating, so scrollHeight keeps growing. distance grows too, so shouldAutoScroll becomes false and the view seems to stop. Then if the user scrolls down close to the threshold again, the logic suddenly jumps to the bottom and throws away the part they were reading.
Version Two: Add A State Machine
Once I realized this was a state problem, I made the "following mode" explicit.
type ScrollMode = 'following' | 'free'
let mode: ScrollMode = 'following'
function onUserScroll(container: HTMLElement) {
const distance = container.scrollHeight - container.scrollTop - container.clientHeight
if (distance > 100) {
mode = 'free' // User intentionally left the bottom
} else if (distance < 10) {
mode = 'following' // User returned to the bottom
}
}
function onContentUpdate(container: HTMLElement) {
if (mode === 'following') {
container.scrollTop = container.scrollHeight
}
}
The key is to separate "the user moved the page" from "new content changed the page height." User movement changes the mode. New content only scrolls automatically while the mode is following.
There is one more detail: how do you tell the difference between a user scroll and a scroll caused by your own scrollTop = scrollHeight? Setting scrollTop also fires a scroll event. I usually handle that with a short flag:
let isProgrammaticScroll = false
function onContentUpdate(container: HTMLElement) {
if (mode === 'following') {
isProgrammaticScroll = true
container.scrollTop = container.scrollHeight
requestAnimationFrame(() => {
isProgrammaticScroll = false
})
}
}
container.addEventListener('scroll', () => {
if (isProgrammaticScroll) return
// Real user scroll logic
})
Long Conversations And Performance
The problem is not only state. Once a conversation grows to thousands of rows, keeping every DOM node mounted makes scrolling itself slow. Then you need virtualization: render only the items near the viewport.
class VirtualScroller {
itemHeight: number
visibleCount: number
totalCount: number
buffer: number
getVisibleRange(scrollTop: number): [number, number] {
const start = Math.floor(scrollTop / this.itemHeight)
const end = Math.min(start + this.visibleCount + this.buffer, this.totalCount)
return [Math.max(0, start - this.buffer), end]
}
}
Virtualization makes "scroll to bottom" harder. scrollHeight is now a virtual height, not the real height of every rendered node. scrollTop = scrollHeight no longer always means "show the last message." You often need a dedicated scrollToIndex(lastIndex) instead.
Hidden Edge Cases
There are more traps than the first version suggests.
- Window resizing. When the browser window changes size,
clientHeightchanges and the distance calculation needs to run again. -
Font-size changes. If the user zooms the page, each item height changes. Virtual scrolling that assumes a fixed
itemHeightcan break. - Images loading late. If the model outputs an image, its height may be 0 before it loads and much larger after it loads, causing the scroll position to jump.
-
Keyboard navigation. Page Up should count as "the user wants to browse freely" and move the mode to
free. -
Touch scrolling. On mobile,
wheelevents are not enough. You need to listen to touch movement too.
None of these cases is especially hard alone. Together, they are enough to turn a small feature into a week of work.
Bottom Line
The main thing I learned is that features involving user intent should not try to guess the user's mind. They should record what the user actually did. "Smart following" is mostly a state machine over user scroll behavior: intentionally leaving, intentionally returning, and passive jumps caused by content changes. Machine learning is not the answer here. A clear state machine is.