Skip to content

Virtuoso Message Custom Smooth Scrolling

In addition to the default 'smooth' and 'auto' scroll behavior values, the component accepts a custom function that accepts the current scroll location, the target scroll location and returns a custom payload that describes a smooth scroll.

@virtuoso.dev/message-list 1.18.0 adds the optional retarget: true field to custom smooth-scroll behavior. When another scroll request uses a retargetable behavior while an earlier retargetable animation is active, the list updates the current animation’s destination instead of restarting it. The animation preserves its current velocity and continues toward the new target.

Use retargeting for live tails where appends, streaming content, or replacement batches repeatedly move the bottom during an active animation:

const liveTailScroll = () => ({
  animationFrameCount: 15,
  easing: (progress: number) => 1 - (1 - progress) * (1 - progress),
  retarget: true,
})

ref.current.data.append(incomingMessages, ({ atBottom, scrollInProgress }) =>
  atBottom || scrollInProgress
    ? {
        index: 'LAST',
        align: 'end',
        behavior: liveTailScroll,
      }
    : false
)

Both the active animation and the new request must set retarget: true. Other smooth-scroll behaviors keep their previous behavior and start a new animation for each request. The callback above returns false when the user has scrolled away from the live tail.

import {
  VirtuosoMessageList,
  VirtuosoMessageListProps,
  VirtuosoMessageListMethods,
  VirtuosoMessageListLicense,
} from '@virtuoso.dev/message-list'
import { useRef } from 'react'
import { randTextRange, randPhrase } from '@ngneat/falso'

/**
 * Bounce easing function - https://easings.net/#easeOutBounce. This is just an example, you can use any easing function.
 */
function easeOutBounce(x: number): number {
  const n1 = 7.5625
  const d1 = 2.75

  if (x < 1 / d1) {
    return n1 * x * x
  } else if (x < 2 / d1) {
    return n1 * (x -= 1.5 / d1) * x + 0.75
  } else if (x < 2.5 / d1) {
    return n1 * (x -= 2.25 / d1) * x + 0.9375
  } else {
    return n1 * (x -= 2.625 / d1) * x + 0.984375
  }
}

/**
 * If the location is too far, you can return a different smooth scroll behavior
 */
function customSmoothScroll(currentTop: number, targetTop: number) {
  return {
    // increase the animation frame count to smoothen and slow down the scroll.
    animationFrameCount: 50,
    easing: easeOutBounce,
  }
}

export default function App() {
  const virtuoso = useRef<VirtuosoMessageListMethods<Message>>(null)

  return (
    <div
      className="wide-example full-code"
      style={{
        height: 500,
        display: 'flex',
        flexDirection: 'column',
        fontSize: '70%',
      }}
    >
      <VirtuosoMessageListLicense licenseKey="">
        <VirtuosoMessageList<Message, null>
          initialData={Array.from({ length: 100 }, (_, index) => randomMessage(index % 2 === 0 ? 'me' : 'other'))}
          ref={virtuoso}
          style={{ flex: 1 }}
          computeItemKey={({ data }) => data.key}
          initialLocation={{ index: 'LAST', align: 'end' }}
          ItemContent={ItemContent}
        />
      </VirtuosoMessageListLicense>

      <button
        style={{ marginTop: '1rem', fontSize: '1.1rem', padding: '1rem' }}
        onClick={(e) => {
          e.target.disabled = true
          const myMessage = randomMessage('me')
          virtuoso.current?.data.append([myMessage], ({ scrollInProgress, atBottom }) => {
            return {
              index: 'LAST',
              align: 'start',
              behavior: atBottom || scrollInProgress ? customSmoothScroll : 'auto',
            }
          })

          setTimeout(() => {
            const botMessage = randomMessage('other')
            virtuoso.current?.data.append([botMessage])

            let counter = 0
            const interval = setInterval(() => {
              if (counter++ > 20) {
                clearInterval(interval)
                e.target.disabled = false
              }
              virtuoso.current?.data.map((message) => {
                return message.key === botMessage.key ? { ...message, text: message.text + ' ' + randPhrase() } : message
              }, 'smooth')
            }, 150)
          }, 1000)
        }}
      >
        Ask the bot a question!
      </button>
    </div>
  )
}

interface Message {
  key: string
  text: string
  user: 'me' | 'other'
}

let idCounter = 0

function randomMessage(user: Message['user']): Message {
  return {
    user,
    key: `${idCounter++}`,
    text: randTextRange({ min: user === 'me' ? 20 : 100, max: 200 }),
  }
}

const ItemContent: VirtuosoMessageListProps<Message, null>['ItemContent'] = ({ data }) => {
  const ownMessage = data.user === 'me'
  return (
    <div style={{ paddingBottom: '2rem', display: 'flex' }}>
      <div
        style={{
          maxWidth: '60%',
          marginLeft: data.user === 'me' ? 'auto' : undefined,

          background: ownMessage ? 'var(--background)' : 'var(--alt-background)',
          border: '1px solid var(--border)',
          borderRadius: '1rem',
          padding: '1rem',
        }}
      >
        {data.text}
      </div>
    </div>
  )
}