Skip to content

Virtuoso Message List Scroll Position Control

The scroll modifier type is the second, optional field of the message list data prop. It specifies any eventual scroll position changes that need to be performed when the data changes. For maximum clarity of your code flow, you should keep the actual data and the scroll modifier associated with it in the same state variable.

The scroll modifier property accepts several string values, some complex objects, as well as null | undefined, which will, in most cases, preserve the current scroll position.

The item location modifier is best used for the initial data display, when you want to have the view start from a specific item in the list. The location property specifies the index of the item to scroll to and the alignment of the item in the viewport. This scroll modifier accepts an optional purgeItemSizes: true field, which will reset the cached item sizes and eventually recalculate them based on the new data. You should be setting this flag if you’re re-using the same component for multiple conversations and you’re switching between them.

setData((current) => ({
  data: Array.from({ length: 100 }, (_, index) => ({ id: index, content: `Message ${index}` })),
  scrollModifier: {
    type: 'item-location',
    location: {
      index: 'LAST',
      align: 'end', // start with the message at the bottom of the viewport
    },
  },
}))

This modifier should be used when new messages are added to the end of the list, and you want to scroll to the bottom of the list automatically (or conditionally, depending on the current position). An extensive example that uses this type can be found in the “receiving messages” chapter of the tutorial - the behavior can be a callback function that receives the current state of the list and calculates the necessary location and scroll behavior.

The autoScroll field accepts either a direct scroll behavior or a callback. Passing a direct behavior such as 'smooth' applies only when the list is already at the bottom. Use the callback form when the policy depends on the source of the event. Returning false is the preserve-position path for users who have scrolled up. Returning a behavior, true, or an item location scrolls the list.

The callback receives the scroll location from before the data change:

  • scrollLocation: the full ListScrollLocation object.
  • atBottom: whether the list was at the bottom before the change.
  • scrollInProgress: whether the list was already scrolling.
  • data: the appended or next data array, depending on the operation.
  • context: the current message-list context.

For incoming remote messages, preserve users who are reading history:

const ReceivedMessagesScrollModifier: ScrollModifier = {
  type: 'auto-scroll-to-bottom',
  autoScroll: ({ atBottom, scrollInProgress }) => {
    if (atBottom || scrollInProgress) {
      return 'smooth'
    }
    return false
  },
}

The package also exports helpers for common policies:

import { scrollToBottomAlways, scrollToBottomIfAtBottom } from '@virtuoso.dev/message-list'

const RemoteMessageScrollModifier: ScrollModifier = {
  type: 'auto-scroll-to-bottom',
  autoScroll: scrollToBottomIfAtBottom,
}

const LocalSendScrollModifier: ScrollModifier = {
  type: 'auto-scroll-to-bottom',
  autoScroll: scrollToBottomAlways,
}

Use scrollToBottomIfAtBottom for incoming messages or row growth where a scrolled-up user should stay in place. Use scrollToBottomAlways for local actions, such as sending the current user’s message, where the new item should become visible even if the user was reading older messages.

The "prepend" scroll modifier value signals that the data change will add messages to the top of the list. The scroll position will be adjusted to keep the current scroll position relative to the new data.

// ....
setData((current) => ({
  ...current,
  data: [...newMessages, ...current.data],
  scrollModifier: 'prepend',
}))

This scroll modifier is useful when the data update keeps the same items but modifies their content/props - such an example would be a streaming bot response, or adding/removing reactions to certain messages. In this case, the scroll modifier lets you keep the message list scrolled to the bottom in case it is there. See the reactions example for a live example.

Use this modifier when the data array update is what changes the rendered item height:

setData((current) => ({
  data: current.data.map((message) => (message.id === streamingId ? { ...message, text: message.text + chunk } : message)),
  scrollModifier: { type: 'items-change', behavior: 'smooth' },
}))

If rendered rows change size because of context, side maps, expansion state, reactions stored outside the data array, approval state, activity loading, or other external row state, use the imperative notifyItemsChanged method instead. ResizeObserver will measure the rows either way; the modifier or method supplies the policy for whether the list should remain stuck to the bottom.

const messageListRef = useRef<VirtuosoMessageListMethods<Message, MessageListContext>>(null)

function setMessageExpanded(messageId: string, expanded: boolean) {
  setExpandedMessages((current) => new Map(current).set(messageId, expanded))
  messageListRef.current?.notifyItemsChanged({ scrollToBottom: scrollToBottomIfAtBottom })
}

The "remove-from-start" scroll modifier value signals that the data change will remove messages from the top of the list. The scroll position will be adjusted to keep the current scroll position relative to the new data. This is useful if you want to trim a data set that has grown too large, but you want to keep the current scroll position.

// ....
setData((current) => ({
  ...current,
  data: current.data.slice(10),
  scrollModifier: 'remove-from-start',
}))

Similar to the remove-from-start, the "remove-from-end" scroll modifier value signals that the data change will remove messages from the bottom of the list. The scroll position will be adjusted to keep the current scroll position relative to the new data.

// ....
setData((current) => ({
  ...current,
  data: current.data.slice(0, -10),
  scrollModifier: 'remove-from-end',
}))