name: message-list description: >- Build chat, messaging, and AI conversation UIs with @virtuoso.dev/message-list. Use this skill when (1) building a chat or messaging interface, (2) streaming AI assistant responses into a conversation, (3) loading older messages when scrolling up, (4) adding scroll-to-bottom buttons or unseen-message indicators, (5) switching between channels/conversations, or (6) any task involving VirtuosoMessageList, VirtuosoMessageListLicense, useVirtuosoMethods, useVirtuosoLocation, scrollModifier, or data.append/prepend/map. The package is commercial and requires a license key.
@virtuoso.dev/message-list
VirtuosoMessageList is a virtualized list purpose-built for human and AI chat: stick-to-bottom behavior, streaming responses that grow without scroll jumps, history prepending that preserves the visual position, and scroll position tracking. Use it instead of plain Virtuoso when building conversation UIs — these behaviors are built in rather than hand-assembled.
Licensing
The package is commercial (annual, per-developer). Every instance must be wrapped in VirtuosoMessageListLicense:
<VirtuosoMessageListLicense licenseKey={licenseKey}>
<VirtuosoMessageList ... />
</VirtuosoMessageListLicense>
An empty licenseKey="" works as a 30-day non-production trial. Validation is local — no network requests. Surface this to the user when introducing the package into a project; keys come from https://virtuoso.dev/pricing/.
Core mental model: data updates carry scroll instructions
Instead of separate imperative scroll calls, each data update includes a scrollModifier describing how the viewport should react:
const [data, setData] = useState<VirtuosoMessageListProps<Message, null>['data']>(() => ({
data: initialMessages,
scrollModifier: { type: 'item-location', location: { index: 'LAST', align: 'end' } },
}))
<VirtuosoMessageList<Message, null> style={{ height: '100%' }} data={data} computeItemKey={({ data }) => data.key} ItemContent={ItemContent} />
ItemContent is a component receiving { data, index, context } props (not positional arguments like react-virtuoso).
Scroll modifier reference
| Modifier | When to use |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | ------ | -------------------------- |
| { type: 'item-location', location, purgeItemSizes? } | Initial load or channel switch; purgeItemSizes: true clears cached sizes when the items are different |
| { type: 'auto-scroll-to-bottom', autoScroll } | New messages arrive; callback receives { atBottom, scrollInProgress, ... } and returns 'smooth' | 'auto' | false or an item location |
| 'prepend' | Older messages added to the top; keeps the current messages visually in place |
| { type: 'items-change', behavior } | Existing items changed size (streaming text, reactions); stays at bottom if already there |
| 'remove-from-start' / 'remove-from-end' | Trimming data while preserving the visual position |
| null / undefined | Leave the scroll position alone |
Common patterns
Receiving messages (auto-scroll when at bottom)
setData((current) => ({
data: [...current.data, incoming],
scrollModifier: {
type: 'auto-scroll-to-bottom',
autoScroll: ({ atBottom, scrollInProgress }) => ({
index: 'LAST',
align: 'end',
behavior: atBottom || scrollInProgress ? 'smooth' : 'auto',
}),
},
}))
Returning false from autoScroll when not at bottom is how you keep the viewport still and instead increment an unseen-messages counter.
Streaming an AI response
Append an empty assistant message, then grow it as tokens arrive:
setData((current) => ({
data: current.data.map((msg) => (msg.key === botKey ? { ...msg, text: msg.text + chunk } : msg)),
scrollModifier: { type: 'items-change', behavior: 'smooth' },
}))
items-change keeps the view pinned to the bottom while the message grows, without jumping if the user scrolled up. See ai-chatbot and gemini (question pinned to top, answer streams below).
Loading older messages on scroll-up
<VirtuosoMessageList
onScroll={(location) => {
if (location.listOffset > -100 && !loading) {
loadOlder().then((older) => setData((current) => ({ data: [...older, ...current.data], scrollModifier: 'prepend' })))
}
}}
/>
No firstItemIndex bookkeeping is needed (unlike plain Virtuoso) — 'prepend' preserves the position automatically.
Scroll-to-bottom button
StickyFooter renders fixed at the bottom of the viewport; combine with the location hook:
const StickyFooter = () => {
const location = useVirtuosoLocation()
const methods = useVirtuosoMethods()
if (location.bottomOffset <= 200) return null
return <button onClick={() => methods.scrollToItem({ index: 'LAST', align: 'end', behavior: 'auto' })}>▼</button>
}
Switching channels
Keep one data object per channel and swap with replace/item-location + purgeItemSizes: true, so size caches from the previous channel don't distort the new one. See multiple-channels.
Imperative API
Via ref={useRef<VirtuosoMessageListMethods<Message>>(null)} from outside, or useVirtuosoMethods() from components rendered inside the list:
data.append(items, scrollToBottom?),data.prepend(items)data.map(fn, autoscrollBehavior?)— update items (reactions, edits, streaming)data.findAndDelete(predicate),data.deleteRange(start, length),data.replace(data, options?)data.find(predicate),data.findIndex(predicate),data.get(),data.getCurrentlyRendered()scrollToItem({ index: number | 'LAST', align, behavior })
The declarative data prop and the imperative data.* methods are alternative ways to drive the same list — pick one as the primary mechanism per component to avoid fighting updates.
Key props and hooks
ItemContent: ({ data, index, context }) => JSX— message renderercontext— shared state (current user, loading flags) available toItemContentand all custom slots; avoids prop drillingcomputeItemKey({ data })— stable message key; required for prepending/streaming to work without remounts- Slots:
Header,Footer(scroll with content),StickyHeader,StickyFooter(fixed, measured to avoid overlap),EmptyPlaceholder initialLocation: { index: 'LAST', align: 'end' }— start at the bottom- Hooks (inside the list):
useVirtuosoMethods(),useVirtuosoLocation()(atBottom,bottomOffset,listOffset,scrollInProgress),useCurrentlyRenderedData()
Pitfalls
- No height → nothing renders. The component needs a real height (
style={{ height: '100%' }}with a sized parent). - Missing
computeItemKeycauses remounts and scroll jumps on prepend and streaming updates. - Margins on message items break height measurement (ResizeObserver excludes margins) — use padding.
- "ResizeObserver loop" errors are benign — filter them in dev overlays and error tracking; see resize-observer-errors.
- JSDOM tests need mocked measurements via
VirtuosoMessageListTestingContext.Provider value={{ itemHeight, viewportHeight }}plus a ResizeObserver polyfill; prefer Playwright for scroll behavior. See testing.
References
- references/README.md — overview and live example
references/2.tutorial/— step-by-step chat build: intro, message-list, loading-older-messages, scroll-to-bottom-button, receive-messages, send-messages, multiple-channelsreferences/3.examples/— messaging, ai-chatbot, gemini, reactions, date-separators, scroll-to-reply, grouped-messages- Feature guides: headers-footers, context, item-keys, hooks, custom-scrollbar, scrolling-to-item, smooth-scrolling, imperative-data-api, scroll-modifier, testing, licensing
Full API reference: https://virtuoso.dev/message-list/