Virtuoso Message List
A virtualized message list component for React, built specifically for human/chatbot conversations.
Features
Section titled “Features”- Virtualized rendering - renders only visible items for optimal performance with large message histories
- Declarative scroll control - scroll position updates via data property with optional smooth animation
- Automatic scroll on new messages - configurable auto-scroll behavior when messages arrive or update
- Customizable components - header, footer, sticky header/footer, loading indicators, scroll-to-bottom buttons
- Flexible positioning - control vertical positioning for short conversations that don’t fill the viewport
- Unstyled by default - easily integrate with your design system and UI component library
License
Section titled “License”The @virtuoso.dev/message-list package is distributed under a commercial license.
Installation
Section titled “Installation”bashQuick Start
Section titled “Quick Start”tsxLive Example
Section titled “Live Example”import {
VirtuosoMessageListProps,
VirtuosoMessageListMethods,
VirtuosoMessageListLicense,
VirtuosoMessageList,
} from '@virtuoso.dev/message-list'
import { randTextRange, randPhrase } from '@ngneat/falso'
import { useRef, useMemo, useState } from 'react'
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,
border: '1px solid var(--border)',
background: ownMessage ? 'var(--background)' : 'var(--alt-background)',
color: 'var(--foreground)',
borderRadius: '1rem',
padding: '1rem',
}}
>
{data.text}
</div>
</div>
)
}
export default function App() {
const virtuoso = useRef<VirtuosoMessageListMethods<Message>>(null)
const [data, setData] = useState<VirtuosoMessageListProps<Message, null>['data']>(() => {
return {
data: Array.from({ length: 100 }, (_, index) => randomMessage(index % 2 === 0 ? 'me' : 'other')),
scrollModifier: {
type: 'item-location',
location: {
index: 'LAST',
align: 'end',
},
},
}
})
return (
<div className="wide-example" style={{ height: 500, display: 'flex', flexDirection: 'column', fontSize: '70%' }}>
<VirtuosoMessageListLicense licenseKey="">
<VirtuosoMessageList<Message, null>
data={data}
ref={virtuoso}
style={{ flex: 1 }}
computeItemKey={({ data }) => data.key}
ItemContent={ItemContent}
/>
</VirtuosoMessageListLicense>
<button
style={{ marginTop: '1rem', fontSize: '1.1rem', padding: '1rem' }}
onClick={(e) => {
;(e.target as HTMLButtonElement).disabled = true
setData((current) => {
const myMessage = randomMessage('me')
return {
data: [...current.data, myMessage],
scrollModifier: {
type: 'auto-scroll-to-bottom',
autoScroll: ({ scrollInProgress, atBottom }) => {
return {
index: 'LAST',
align: 'start',
behavior: atBottom || scrollInProgress ? 'smooth' : 'auto',
}
},
},
}
})
setTimeout(() => {
const botMessage = randomMessage('other')
setData((current) => {
return {
data: [...current.data, botMessage],
}
})
let counter = 0
const interval = setInterval(() => {
if (counter++ > 20) {
clearInterval(interval)
;(e.target as HTMLButtonElement).disabled = false
}
setData((current) => {
return {
data: current.data.map((message) => {
return message.key === botMessage.key ? { ...message, text: message.text + ' ' + randPhrase() } : message
}),
// scroll modifier that will make the list autoscroll when the bot response overflows the viewport.
scrollModifier: {
type: 'items-change',
behavior: 'smooth',
},
}
})
}, 150)
}, 1000)
}}
>
Ask the bot a question!
</button>
</div>
)
}