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:
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.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.
trackVisibility: boolean;
This is pretty much
isLoading
from the Vercel AI SDK. When it istrue
, 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.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 ofisAtBottom
helps theChatScrollAnchor
to know if it should engage auto-scrolling when new messages arrive.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 useinView
, includingentry
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
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.
User scrolls up while new messages are coming in: In this case,
isAtBottom
would befalse
, and theif
statement won't execute, respecting the user's intent to stay where they are.No new messages coming in: Even if the user is at the bottom (
isAtBottom
istrue
), iftrackVisibility
isfalse
, 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!