Intuitive Scrolling for Chatbot Message Streaming

·

8 min read

How do we make a chat interface auto-scroll to the latest message while still allowing users to scroll up to view previous messages without interruption? Without a proper solution, either new messages can get buried, or the user can become frustrated as the chat window keeps jumping to the bottom when they are trying to read previous messages.

Tools and Libraries

For this implementation, we'll make use of:

  • TypeScript

  • React

  • React Intersection Observer

  • Vercel AI SDK (not required, although helpful)

Overview of the Solution

At the core of our solution is the ChatScrollAnchor, an invisible anchor element placed at the bottom of the chat area. The anchor serves two purposes:

  1. Visibility Tracking: Using the useInView hook from React Intersection Observer, the anchor helps us know whether the bottom of the chat area is visible to the user.

  2. Scroll Positioning: If new messages are coming in and the anchor is not in view, we'll programmatically scroll the chat down to the latest message.

The handleScroll function serves to update our state, determining whether the user is at the bottom of the chat or has scrolled up. This state feeds into ChatScrollAnchor, which in turn decides whether or not to auto-scroll based on the anchor's visibility and the current scroll position.

The Code

import * as React from 'react';
import { useInView } from 'react-intersection-observer';

interface ChatScrollAnchorProps {
    trackVisibility: boolean;
    isAtBottom: boolean;
    scrollAreaRef: React.RefObject<HTMLDivElement>;
}

export function ChatScrollAnchor({
    trackVisibility,
    isAtBottom,
    scrollAreaRef,
}: ChatScrollAnchorProps) {
    const { ref, inView, entry } = useInView({
        trackVisibility,
        delay: 100,
    });

    React.useEffect(() => {
        if (isAtBottom && trackVisibility && !inView) {
            if (!scrollAreaRef.current) return;

            const scrollAreaElement = scrollAreaRef.current;

            scrollAreaElement.scrollTop =
                scrollAreaElement.scrollHeight - scrollAreaElement.clientHeight;
        }
    }, [inView, entry, isAtBottom, trackVisibility]);

    return <div ref={ref} className='h-px w-full' />;
}

Now, you can use this component in your chatbox like so:

<ScrollArea
    ref={scrollAreaRef}
    onScroll={handleScroll}
>
    <ChatList messages={messages} />
    <ChatScrollAnchor
        scrollAreaRef={scrollAreaRef}
        isAtBottom={isAtBottom}
        trackVisibility={isLoading}
    />
</ScrollArea>

And finally, here is the handleScroll and useEffect that you'll need:

const [isAtBottom, setIsAtBottom] = useState<boolean>(false);
const scrollAreaRef = useRef<HTMLDivElement>(null);

const handleScroll = () => {
    if (!scrollAreaRef.current) return;

    const { scrollTop, scrollHeight, clientHeight } = scrollAreaRef.current;
    const atBottom = scrollHeight - clientHeight <= scrollTop + 1;

    setIsAtBottom(atBottom);
};

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

        const scrollAreaElement = scrollAreaRef.current;

        scrollAreaElement.scrollTop =
            scrollAreaElement.scrollHeight - scrollAreaElement.clientHeight;

        setIsAtBottom(true);
    }
}, [isLoading]);

Explaining the Code

I'll begin by explaining what the ChatScrollAnchor props are and why they're needed.

  1. trackVisibility: boolean;

    This is pretty much isLoading from the Vercel AI SDK. When it is true, messages are being streamed in. We only need to maintain the bottom scroll positioning when new messages are being shown, so we check whether it is true before we programmatically move the scrollbar to show the new message as it streams in. As the prop name suggests, it just decides whether or not to track the anchor's position.

  2. isAtBottom: boolean;

    This prop represents whether the user's viewport is already at the bottom of the chat. It's updated by the handleScroll function, which listens for scroll events and checks if you are at the bottom of the chat area. The value of isAtBottom helps the ChatScrollAnchor to know if it should engage auto-scrolling when new messages arrive.

  3. scrollAreaRef: React.RefObject<HTMLDivElement>;

    This prop is a React ref pointing to the chat's scrollable area. This reference enables us to directly manipulate the chat's scroll position in the DOM when needed.

Using useInView with trackVisibility

Inside our component, we declare useInView from React Intersection Observer to create a new Intersection Observer for our anchor element. The useInView hook returns several pieces of information, but we are primarily interested in inView, which is a boolean that tells us if our anchor is currently in the viewport or not.

Here's how we declare it:

const { ref, inView, entry } = useInView({
  trackVisibility: trackVisibility,
  delay: 100,
});

The trackVisibility option is set based on our prop of the same name. When trackVisibility is true, it indicates that the Intersection Observer should notify us of visibility changes on our anchor. This is important because we want to be aware of any change in visibility when new messages are being streamed, so we know whether to adjust the scroll position or not. Without this, we won't be able to have continuous updates.

The delay is set to 100 milliseconds, which is the lowest it can be set to when trackVisibility is true. This imposes a delay between notifications, making sure that we are not overwhelmed with updates, which could potentially lead to performance issues.

Utilizing useEffect

We employ useEffect to listen for changes that should trigger an auto-scroll. Within this effect, we use a conditional statement to check if we should adjust the scroll position of our chat area. Here is the simplified logic:

React.useEffect(() => {
  if (isAtBottom && trackVisibility && !inView) {
    // Logic for scrolling down
  }
}, [inView, entry, isAtBottom, trackVisibility]);

The dependencies in our useEffect's dependency array are carefully chosen:

  • inView: This tells us if our anchor is in view. Changes in this value could signify a need to adjust the scroll position.

  • entry: While we primarily use inView, including entry ensures that our effect re-runs whenever the observed intersection changes, providing an additional layer of responsiveness.

  • isAtBottom: Indicates whether the user is at the bottom of the chat area. If this value changes, it could mean that we need to enable or disable auto-scrolling.

  • trackVisibility: When true, we want to track visibility changes to decide whether to scroll to the bottom or not.

Understanding the if Statement in useEffect

if (isAtBottom && trackVisibility && !inView) {
  // Logic for scrolling down
}

This if statement is the control center for when the auto-scroll action should take place. Each part of this conditional checks a different aspect of our chat state, and only when all conditions are met do we programmatically adjust the scroll position. Let's break down what each condition is checking for:

isAtBottom

This condition checks whether the user is currently at the bottom of the chat area. If the user is at the bottom, it's a strong indication that they want to read the most recent messages as they come in.

trackVisibility

This flag indicates whether we should be tracking the visibility of our anchor element or not. In the context of our application, its state is pinned to isLoading, which means we should only be looking to auto-scroll when new messages are being loaded/streamed. If this is false, there are no new messages being streamed, and we do not programmatically scroll.

!inView

This condition checks if the anchor element is out of the viewport. If the anchor is not visible (!inView), it implies that new messages are overflowing, thereby taking the anchor out of the viewport. This serves as a signal that we should adjust the scroll position to reveal the anchor. And since this anchor is below the messages, we show the new message too. This constant adjustment allows for the auto-scrolling effect.

Scenarios Where the if Statement Applies

  1. User is at the bottom, new messages are coming in, anchor is out of view: This is the primary scenario where the auto-scroll is essential. The user is actively waiting for the incoming messages, and we want to keep revealing the new ones without requiring manual scrolling.

  2. User scrolls up while new messages are coming in: In this case, isAtBottom would be false, and the if statement won't execute, respecting the user's intent to stay where they are.

  3. No new messages coming in: Even if the user is at the bottom (isAtBottom is true), if trackVisibility is false, it indicates that no new messages are being added, and thus, there's no need to adjust the scroll position.

Using ChatScrollAnchor in our main component

In this section, we'll dive into the auxiliary components of our chat scrolling logic: the state management using useState, the handleScroll function, and the useEffect hook that operates side effects.

State Management: isAtBottom and setIsAtBottom

const [isAtBottom, setIsAtBottom] = useState<boolean>(false);

This is pretty self-explanatory. We declare a state variable isAtBottom and its corresponding setter function setIsAtBottom. The purpose is to keep track of whether the user is at the bottom of the chat area or not. It starts off as false because the chat may have previous messages and the user might not be at the bottom initially.

The Scroll Area Reference: scrollAreaRef

const scrollAreaRef = useRef<HTMLDivElement>(null);

scrollAreaRef is a React Ref object that holds a reference to the chat scroll area's DOM element. This is essential for programmatically adjusting the scroll position and reading its properties.

The handleScroll Function

const handleScroll = () => {
    if (!scrollAreaRef.current) return;

    const { scrollTop, scrollHeight, clientHeight } = scrollAreaRef.current;
    const atBottom = scrollHeight - clientHeight <= scrollTop + 1;

    setIsAtBottom(atBottom);
};

Whenever a user scrolls in the chat area, handleScroll is invoked. It reads properties like scrollTop, scrollHeight, and clientHeight to calculate whether the user has scrolled to the bottom. We add +1 as an offset. This means that the scrollbar can be 1px off the bottom, and we still consider it to be at the bottom. This may not be entirely necessary, but scrollbar behavior can sometimes be a bit weird, so I find that it helps. If the user is at the bottom, setIsAtBottom updates the isAtBottom state to true.

The useEffect for New Messages

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

        const scrollAreaElement = scrollAreaRef.current;

        scrollAreaElement.scrollTop =
            scrollAreaElement.scrollHeight - scrollAreaElement.clientHeight;

        setIsAtBottom(true);
    }
}, [isLoading]);

When a new message is being sent (isLoading is true), this useEffect ensures the chat area automatically scrolls down to reveal the new content. We do this by setting the scrollTop value to scrollHeight - clientHeight. You may want to remove this depending on your UX philosophy and/or use case. My own belief is that automatically scrolling down when a user sends a new message is better because it allows them to quickly see their own message as well as any new incoming messages without requiring them to manually scroll down. When you send a new message, you are usually done reading the one you scrolled up to.

By combining the states, the handle function, and side-effects, we provide a robust and intuitive auto-scrolling feature that enhances user experience significantly.

Thanks for reading!