From virtuoso-skills
Build chat, messaging, and AI conversation UIs with @virtuoso.dev/message-list. Handles stick-to-bottom, streaming responses, history prepending, and scroll tracking.
How this skill is triggered — by the user, by Claude, or both
Slash command
/virtuoso-skills:message-listThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
`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.
references/10.smooth-scrolling.mdreferences/11.testing.mdreferences/15.imperative-data-api.mdreferences/19.resize-observer-errors.mdreferences/2.tutorial/01.intro.mdreferences/2.tutorial/02.message-list.mdreferences/2.tutorial/03.loading-older-messages.mdreferences/2.tutorial/04.scroll-to-bottom-button.mdreferences/2.tutorial/05.receive-messages.mdreferences/2.tutorial/06.send-messages.mdreferences/2.tutorial/07.multiple-channels.mdreferences/20.scroll-modifier.mdreferences/21.controlled-mode-recipes.mdreferences/3.examples/01.messaging.mdreferences/3.examples/02.ai-chatbot.mdreferences/3.examples/03.gemini.mdreferences/3.examples/04.reactions.mdreferences/3.examples/05.date-separators.mdreferences/3.examples/06.scroll-to-reply.mdreferences/3.examples/07.grouped-messages.mdVirtuosoMessageList 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.
The package is commercial (annual, per-developer). Every instance must be wrapped in VirtuosoMessageListLicense:
<VirtuosoMessageListLicense licenseKey={licenseKey}>
<VirtuosoMessageList ... />
</VirtuosoMessageListLicense>
An empty licenseKey="" is licensed for 30-day non-production evaluation: it keeps rendering in development with a console reminder, while production without a valid key renders an error. 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/. Prefer an app-level wrapper that reads from the normal public client-side environment configuration.
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). Extract it outside the parent render function unless it needs to close over local state.
External row state is different from data updates. If context, side maps, expansion state, reactions, approvals, or loading flags can change row height without changing the data array, call notifyItemsChanged({ scrollToBottom }) on the message-list ref after updating that state.
| 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 are appended; callback can return false, a behavior, true, or an item location |
'prepend' | Older messages added to the top; keeps the current messages visually in place |
{ type: 'items-change', behavior } | Existing data items changed size, such as streaming text or reactions stored in the data item |
'remove-from-start' / 'remove-from-end' | Trimming data while preserving the visual position |
null / undefined | Leave the scroll position alone |
In callback form, returning a behavior or item location scrolls even when atBottom is false. Return false to preserve the viewport for a scrolled-up user.
import { scrollToBottomIfAtBottom } from '@virtuoso.dev/message-list'
setData((current) => ({
data: [...current.data, incoming],
scrollModifier: {
type: 'auto-scroll-to-bottom',
autoScroll: scrollToBottomIfAtBottom,
},
}))
Use scrollToBottomIfAtBottom for remote messages. It returns 'smooth' when the list is already at the bottom or already scrolling there, otherwise false. If the app needs an unseen-message counter, use the inline callback form and increment the counter before returning false.
import { scrollToBottomAlways } from '@virtuoso.dev/message-list'
setData((current) => ({
data: [...current.data, optimisticLocalMessage],
scrollModifier: {
type: 'auto-scroll-to-bottom',
autoScroll: scrollToBottomAlways,
},
}))
Use scrollToBottomAlways for local current-user actions where the new last item should become visible even if the user was scrolled up. If a realtime socket echoes the same optimistic message back, reconcile it by localId or another client-generated identity and treat it as confirmation, not a new incoming remote message.
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).
Use notifyItemsChanged when row content changes size without changing the data array:
import { scrollToBottomIfAtBottom, type VirtuosoMessageListMethods } from '@virtuoso.dev/message-list'
const messageListRef = useRef<VirtuosoMessageListMethods<Message, MessageListContext>>(null)
setExpandedMessages((current) => new Map(current).set(messageId, expanded))
messageListRef.current?.notifyItemsChanged({ scrollToBottom: scrollToBottomIfAtBottom })
ResizeObserver measures the rows; notifyItemsChanged supplies the scroll policy. Use it for expansion state in context, side maps for reactions or approvals, activity loading, or any other external row state.
<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.
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>
}
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.
For controlled data prop usage, keep DataWithScrollModifier<Message> behind app-owned actions such as replaceMessages, appendIncoming, appendLocal, confirmLocal, updateStreamingMessage, and rowChromeChanged. This is a recipe layer in app code, not an official exported hook and not a replacement for the imperative data.* API.
replaceMessages: channel or thread switch with { type: 'item-location', location: { index: 'LAST', align: 'end' }, purgeItemSizes: true }.appendIncoming: remote messages with scrollToBottomIfAtBottom.appendLocal: optimistic local sends with scrollToBottomAlways.confirmLocal: match the socket/server acknowledgement by localId and update the existing row.updateStreamingMessage: map the existing message and use { type: 'items-change', behavior: 'smooth' }.rowChromeChanged: update external row state and call notifyItemsChanged({ scrollToBottom: scrollToBottomIfAtBottom }).Do not invent or recommend a public useVirtuosoMessageListData hook before trying this recipe-level controlled state in app code. See controlled-mode-recipes.
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()notifyItemsChanged({ scrollToBottom }) — use after context or side-state changes that can resize rendered rows without changing the data arrayscrollToItem({ 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.
ItemContent: ({ data, index, context }) => JSX — message renderercontext — shared state (current user, loading flags) available to ItemContent and all custom slots; avoids prop drillingcomputeItemKey({ data }) — stable message key; required for prepending/streaming to work without remountsitemIdentity(item) — stable item identity for controlled prepend/remove-from-start matching when reducers recreate item objectsHeader, Footer (scroll with content), StickyHeader, StickyFooter (fixed, measured to avoid overlap), EmptyPlaceholderinitialLocation: { index: 'LAST', align: 'end' } — start at the bottomuseVirtuosoMethods(), useVirtuosoLocation() (atBottom, bottomOffset, listOffset, scrollInProgress), useCurrentlyRenderedData()style={{ height: '100%' }} with a sized parent).VirtuosoMessageListLicense; an empty key is only for non-production evaluation.computeItemKey causes remounts and scroll jumps on prepend and streaming updates.itemIdentity in controlled mode can break prepend/remove-from-start matching when reducers recreate item objects.items-change, and context-only row growth uses notifyItemsChanged.VirtuosoMessageListTestingContext.Provider value={{ itemHeight, viewportHeight }} plus a ResizeObserver polyfill; prefer Playwright for scroll behavior. See testing.VirtuosoMessageListLicense wrapper and decide how the key is supplied.ItemContent and pass shared state through context.computeItemKey for message ids and local optimistic ids.itemIdentity when controlled data recreates objects and uses prepend/trimming.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-messagesFull API reference: https://virtuoso.dev/message-list/
npx claudepluginhub petyosi/react-virtuoso --plugin virtuoso-skillsBuilds virtualized lists, grids, and tables with react-virtuoso. Handles variable heights, grouped lists, sticky headers, responsive grids, and chat-like feeds automatically.
Virtualizes MUI lists with react-window FixedSizeList/VariableSizeList, react-virtuoso, and Autocomplete patterns for rendering 1000+ items without layout thrashing or memory issues.
Builds production Next.js web chatbots with AI SDK 6 + ai-elements. Covers tool calling with HITL approval, PostgreSQL session persistence, GDPR consent, SQL-first search, per-tool UI rendering, and evals. Use for conversational interfaces needing tool approval, database sessions, or custom tool output components.