Virtuoso Message Custom Smooth Scrolling
Custom Smooth Scrolling
Section titled “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.
Retargeting a Moving Destination
Section titled “Retargeting a Moving Destination”@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>
)
}