MessageScroller
MessageScroller scrolls a message stream and keeps the latest turn in view. Use it for chat and assistant threads that grow as new turns arrive. For other scrolling content, use ScrollArea.
'use client';
import { AvatarFallback } from '@/components/nxds/components/avatar/avatar';
import { Bubble, BubbleContent } from '@/components/nxds/components/bubble/bubble';
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from '@/components/nxds/components/message-scroller/message-scroller';
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
} from '@/components/nxds/components/message/message';
const frame =
'nx:h-80 nx:w-full nx:max-w-lg nx:rounded-md nx:border-default nx:border-border-default nx:bg-background';
function Turn({ index }: { index: number }) {
const own = index % 2 === 1;
return (
<MessageGroup>
<Message align={own ? 'end' : 'start'}>
<MessageAvatar>
<AvatarFallback>{own ? 'YOU' : 'AB'}</AvatarFallback>
</MessageAvatar>
<MessageContent>
<Bubble variant={own ? 'primary' : 'muted'}>
<BubbleContent>Turn {index + 1} in the transcript.</BubbleContent>
</Bubble>
<MessageFooter>
09:{String(10 + index).padStart(2, '0')}
</MessageFooter>
</MessageContent>
</Message>
</MessageGroup>
);
}
export default function MessageScrollerAllVariants() {
return (
<MessageScrollerProvider>
<MessageScroller className={frame}>
<MessageScrollerViewport>
<MessageScrollerContent>
{Array.from({ length: 14 }, (_, index) => (
<MessageScrollerItem key={index}>
<Turn index={index} />
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton direction="start" />
<MessageScrollerButton direction="end" />
</MessageScroller>
</MessageScrollerProvider>
);
}
This example also needs: Bubble, Message.
Installation
npx nxds-cli@latest add message-scroller
Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.
Usage
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from '@/components/nxds/components/message-scroller/message-scroller';
Composition
MessageScrollerProvider
└── MessageScroller
├── MessageScrollerViewport
│ └── MessageScrollerContent
│ └── MessageScrollerItem
└── MessageScrollerButton
MessageScrollerButton
Examples
Empty stream
Show an EmptyState inside the scroller before the first message, with the jump button hidden.
Send the first message to start this conversation.
'use client';
import { ChatTextIcon } from '@phosphor-icons/react/ssr';
import {
EmptyState,
EmptyStateDescription,
EmptyStateHeader,
EmptyStateMedia,
EmptyStateTitle,
} from '@/components/nxds/components/empty-state/empty-state';
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerProvider,
MessageScrollerViewport,
} from '@/components/nxds/components/message-scroller/message-scroller';
const frame =
'nx:h-80 nx:w-full nx:max-w-lg nx:rounded-md nx:border-default nx:border-border-default nx:bg-background';
export default function MessageScrollerEmptyStream() {
return (
<MessageScrollerProvider>
<MessageScroller className={frame}>
<MessageScrollerViewport>
<MessageScrollerContent>
<EmptyState>
<EmptyStateHeader>
<EmptyStateMedia variant="icon">
<ChatTextIcon />
</EmptyStateMedia>
<EmptyStateTitle>No messages yet</EmptyStateTitle>
<EmptyStateDescription>
Send the first message to start this conversation.
</EmptyStateDescription>
</EmptyStateHeader>
</EmptyState>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
);
}
This example also needs: EmptyState.
Scroll to start
Add a direction="start" button that jumps to the oldest turn.
'use client';
import * as React from 'react';
import { AvatarFallback } from '@/components/nxds/components/avatar/avatar';
import { Bubble, BubbleContent } from '@/components/nxds/components/bubble/bubble';
import { Button } from '@/components/nxds/components/button/button';
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
} from '@/components/nxds/components/message-scroller/message-scroller';
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
} from '@/components/nxds/components/message/message';
const frame =
'nx:h-80 nx:w-full nx:max-w-lg nx:rounded-md nx:border-default nx:border-border-default nx:bg-background';
function Turn({ index }: { index: number }) {
const own = index % 2 === 1;
return (
<MessageGroup>
<Message align={own ? 'end' : 'start'}>
<MessageAvatar>
<AvatarFallback>{own ? 'YOU' : 'AB'}</AvatarFallback>
</MessageAvatar>
<MessageContent>
<Bubble variant={own ? 'primary' : 'muted'}>
<BubbleContent>Turn {index + 1} in the transcript.</BubbleContent>
</Bubble>
<MessageFooter>
09:{String(10 + index).padStart(2, '0')}
</MessageFooter>
</MessageContent>
</Message>
</MessageGroup>
);
}
function Stream({
count,
children,
...props
}: { count: number } & React.ComponentProps<typeof MessageScroller>) {
return (
<MessageScroller className={frame} {...props}>
<MessageScrollerViewport>
<MessageScrollerContent>
{Array.from({ length: count }, (_, index) => (
<MessageScrollerItem key={index}>
<Turn index={index} />
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
{children}
</MessageScroller>
);
}
function Composer({ onAppend }: { onAppend: () => void }) {
const { scrollToEnd } = useMessageScroller();
return (
<div className="nx:flex nx:gap-2">
<Button size="sm" variant="outline" onClick={onAppend}>
Append turn
</Button>
<Button
size="sm"
onClick={() => {
onAppend();
scrollToEnd();
}}
>
Send
</Button>
</div>
);
}
function AppendableStream({ children }: { children?: React.ReactNode }) {
const [count, setCount] = React.useState(10);
return (
<MessageScrollerProvider>
<div className="nx:flex nx:flex-col nx:gap-3">
<Stream count={count}>{children}</Stream>
<Composer onAppend={() => setCount((value) => value + 1)} />
</div>
</MessageScrollerProvider>
);
}
export default function MessageScrollerScrollToStart() {
return (
<AppendableStream>
<MessageScrollerButton direction="start" />
</AppendableStream>
);
}
Send from composer
Call scrollToEnd from useMessageScroller in a composer to jump to the new turn on send.
'use client';
import * as React from 'react';
import { AvatarFallback } from '@/components/nxds/components/avatar/avatar';
import { Bubble, BubbleContent } from '@/components/nxds/components/bubble/bubble';
import { Button } from '@/components/nxds/components/button/button';
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
} from '@/components/nxds/components/message-scroller/message-scroller';
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
} from '@/components/nxds/components/message/message';
const frame =
'nx:h-80 nx:w-full nx:max-w-lg nx:rounded-md nx:border-default nx:border-border-default nx:bg-background';
function Turn({ index }: { index: number }) {
const own = index % 2 === 1;
return (
<MessageGroup>
<Message align={own ? 'end' : 'start'}>
<MessageAvatar>
<AvatarFallback>{own ? 'YOU' : 'AB'}</AvatarFallback>
</MessageAvatar>
<MessageContent>
<Bubble variant={own ? 'primary' : 'muted'}>
<BubbleContent>Turn {index + 1} in the transcript.</BubbleContent>
</Bubble>
<MessageFooter>
09:{String(10 + index).padStart(2, '0')}
</MessageFooter>
</MessageContent>
</Message>
</MessageGroup>
);
}
function Stream({
count,
children,
...props
}: { count: number } & React.ComponentProps<typeof MessageScroller>) {
return (
<MessageScroller className={frame} {...props}>
<MessageScrollerViewport>
<MessageScrollerContent>
{Array.from({ length: count }, (_, index) => (
<MessageScrollerItem key={index}>
<Turn index={index} />
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
{children}
</MessageScroller>
);
}
function Composer({ onAppend }: { onAppend: () => void }) {
const { scrollToEnd } = useMessageScroller();
return (
<div className="nx:flex nx:gap-2">
<Button size="sm" variant="outline" onClick={onAppend}>
Append turn
</Button>
<Button
size="sm"
onClick={() => {
onAppend();
scrollToEnd();
}}
>
Send
</Button>
</div>
);
}
function AppendableStream({ children }: { children?: React.ReactNode }) {
const [count, setCount] = React.useState(10);
return (
<MessageScrollerProvider>
<div className="nx:flex nx:flex-col nx:gap-3">
<Stream count={count}>{children}</Stream>
<Composer onAppend={() => setCount((value) => value + 1)} />
</div>
</MessageScrollerProvider>
);
}
export default function MessageScrollerSendFromComposer() {
return <AppendableStream />;
}
Props
MessageScroller
MessageScroller adds no props of its own; it accepts the props of the element or primitive it wraps.
MessageScrollerButton
| Prop | Type | Default | Description |
|---|---|---|---|
direction | "end" | "start" | null | "end" | —No description |
endIcon | ReactNode | —No default documented | Decorative icon after the label. Use |
loading | boolean | false | Disables the button and shows a spinner in place of its content. |
size | "default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null | "icon-sm" | —No description |
startIcon | ReactNode | —No default documented | Decorative icon before the label. Use |
variant | "link" | "default" | "error" | "destructive" | "outline" | "dashed" | "secondary" | "ghost" | null | "outline" | —No description |
MessageScrollerContent
MessageScrollerContent adds no props of its own; it accepts the props of the element or primitive it wraps.
MessageScrollerItem
MessageScrollerItem adds no props of its own; it accepts the props of the element or primitive it wraps.
MessageScrollerProvider
MessageScrollerProvider adds no props of its own; it accepts the props of the element or primitive it wraps.
MessageScrollerViewport
MessageScrollerViewport adds no props of its own; it accepts the props of the element or primitive it wraps.
MessageScroller scrolls a message stream and keeps the latest turn in view. Use it for chat and assistant threads that grow as new turns arrive. For other scrolling content, use ScrollArea.
<script setup lang="ts">
import { AvatarFallback } from '@/components/avatar';
import { Bubble, BubbleContent } from '@/components/bubble';
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
} from '@/components/message';
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from '@/components/message-scroller';
</script>
<template>
<MessageScrollerProvider>
<MessageScroller
class="nx:h-80 nx:w-full nx:max-w-lg nx:rounded-md nx:border-default nx:border-border-default nx:bg-background"
>
<MessageScrollerViewport>
<MessageScrollerContent>
<MessageScrollerItem v-for="index in 14" :key="index">
<MessageGroup>
<Message :align="index % 2 === 0 ? 'end' : 'start'">
<MessageAvatar>
<AvatarFallback>{{
index % 2 === 0 ? 'YOU' : 'AB'
}}</AvatarFallback>
</MessageAvatar>
<MessageContent>
<Bubble :variant="index % 2 === 0 ? 'primary' : 'muted'">
<BubbleContent
>Turn {{ index }} in the transcript.</BubbleContent
>
</Bubble>
<MessageFooter
>09:{{ String(9 + index).padStart(2, '0') }}</MessageFooter
>
</MessageContent>
</Message>
</MessageGroup>
</MessageScrollerItem>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton direction="start" />
<MessageScrollerButton direction="end" />
</MessageScroller>
</MessageScrollerProvider>
</template>
This example also needs: Avatar, Bubble, Message.
Installation
- 1
Install these components first:
- 2
Copy and paste the following code into your project.
components/message-scroller/MessageScroller.vue <script setup lang="ts"> import { reactiveOmit } from '@vueuse/core'; import { ScrollAreaCorner, ScrollAreaRoot, type ScrollAreaRootProps, } from 'reka-ui'; import type { HTMLAttributes } from 'vue'; import { cn } from '../../lib/utils'; import { ScrollBar } from '../scroll-area'; import { useMessageScroller } from '.'; export interface MessageScrollerProps extends ScrollAreaRootProps { class?: HTMLAttributes['class']; } const props = defineProps<MessageScrollerProps>(); const delegated = reactiveOmit(props, 'class'); const { isAtStart, isAtEnd } = useMessageScroller(); </script> <template> <ScrollAreaRoot v-bind="delegated" data-slot="message-scroller" :data-at-start="isAtStart" :data-at-end="isAtEnd" :class=" cn( 'nx:relative nx:flex nx:min-h-0 nx:w-full nx:flex-col nx:overflow-hidden', props.class ) " > <slot /> <ScrollBar /> <ScrollAreaCorner data-slot="message-scroller-corner" /> </ScrollAreaRoot> </template>components/message-scroller/MessageScrollerButton.vue <script setup lang="ts"> import { reactiveOmit } from '@vueuse/core'; import { computed, type VNode } from 'vue'; import { IconArrowDown, IconArrowUp } from '../../lib/icons'; import { cn } from '../../lib/utils'; import { Button, type ButtonProps } from '../button'; import { type MessageScrollerButtonVariants, messageScrollerButtonVariants, useMessageScroller, } from '.'; export interface MessageScrollerButtonProps extends Omit< ButtonProps, 'asChild' > { direction?: MessageScrollerButtonVariants['direction']; } const props = withDefaults(defineProps<MessageScrollerButtonProps>(), { as: 'button', direction: 'end', variant: 'outline', size: 'icon-sm', loading: false, type: 'button', }); const slots = defineSlots<{ 'start-icon'?: () => VNode[]; 'end-icon'?: () => VNode[]; }>(); const delegated = reactiveOmit(props, 'class', 'direction'); const { isScrollable, isAtEnd, isAtStart, scrollToEnd, scrollToStart } = useMessageScroller(); const isActive = computed( () => isScrollable.value && (props.direction === 'end' ? !isAtEnd.value : !isAtStart.value) ); </script> <template> <Button v-bind="delegated" data-slot="message-scroller-button" :data-direction="direction" :data-active="isActive" :aria-label=" direction === 'end' ? 'Scroll to the latest message' : 'Scroll to the oldest message' " :class="cn(messageScrollerButtonVariants({ direction }), props.class)" @click="direction === 'end' ? scrollToEnd() : scrollToStart()" > <template v-if="slots['start-icon']" #start-icon> <slot name="start-icon" /> </template> <IconArrowDown v-if="direction === 'end'" /> <IconArrowUp v-else /> <template v-if="slots['end-icon']" #end-icon> <slot name="end-icon" /> </template> </Button> </template>components/message-scroller/MessageScrollerContent.vue <script setup lang="ts"> import type { ComponentPublicInstance, HTMLAttributes } from 'vue'; import { cn } from '../../lib/utils'; import { useMessageScrollerNodes } from './message-scroller-context'; export interface MessageScrollerContentProps { class?: HTMLAttributes['class']; } const props = defineProps<MessageScrollerContentProps>(); const { setContent } = useMessageScrollerNodes(); function registerContent(node: Element | ComponentPublicInstance | null) { setContent(node instanceof HTMLElement ? node : null); } </script> <template> <div :ref="registerContent" data-slot="message-scroller-content" :class=" cn( 'nx:flex nx:h-max nx:min-h-full nx:flex-col nx:gap-6 nx:p-4', props.class ) " > <slot /> </div> </template>components/message-scroller/MessageScrollerItem.vue <script setup lang="ts"> import type { HTMLAttributes } from 'vue'; import { cn } from '../../lib/utils'; export interface MessageScrollerItemProps { class?: HTMLAttributes['class']; } const props = defineProps<MessageScrollerItemProps>(); </script> <template> <div data-slot="message-scroller-item" :class="cn('nx:min-w-0 nx:shrink-0', props.class)" > <slot /> </div> </template>components/message-scroller/MessageScrollerProvider.vue <script setup lang="ts"> import { provide, readonly, ref, shallowRef, type VNode, watch } from 'vue'; import { MessageScrollerKey } from './message-scroller-context'; export type MessageScrollerProviderProps = Record<string, never>; defineSlots<{ default?: (props: { isScrollable: boolean; isAtEnd: boolean; isAtStart: boolean; scrollToEnd: () => void; scrollToStart: () => void; }) => VNode[]; }>(); /** Slack a scroll position gets before it stops counting as "at the edge". */ const EDGE_THRESHOLD = 24; // Inline, so `reduceMotion`'s `scroll-behavior: auto !important` still wins. function smoothScrollTo(viewport: HTMLElement, top: number) { const previous = viewport.style.scrollBehavior; viewport.style.scrollBehavior = 'smooth'; viewport.scrollTo({ top }); viewport.style.scrollBehavior = previous; } const viewportNode = shallowRef<HTMLElement | null>(null); const contentNode = shallowRef<HTMLElement | null>(null); let pinned = true; // Set while `scrollToStart` animates away from the end, whose first frames // are still near enough to the end to re-pin. let leavingEnd = false; const isScrollable = ref(false); const isAtEnd = ref(true); const isAtStart = ref(true); watch( [viewportNode, contentNode], ([viewport, content], _, onCleanup) => { if (!viewport || !content) return; let lastScrollTop = 0; const isNearEnd = () => viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= EDGE_THRESHOLD; const measure = () => { lastScrollTop = viewport.scrollTop; isAtEnd.value = isNearEnd(); isAtStart.value = viewport.scrollTop <= EDGE_THRESHOLD; isScrollable.value = viewport.scrollHeight - viewport.clientHeight > 1; }; // A scroll event can be dispatched after content has already grown, so it // would measure the old position against the new height. Only an upward // move releases the pin; reaching the end restores it, except while // `scrollToStart` is still animating away from the end. const handleScroll = () => { const { scrollTop } = viewport; if (scrollTop < lastScrollTop) pinned = false; if (scrollTop > lastScrollTop || scrollTop === 0) leavingEnd = false; if (isNearEnd() && !leavingEnd) pinned = true; measure(); }; const holdEnd = () => { if (pinned) viewport.scrollTo({ top: viewport.scrollHeight }); measure(); }; const observer = new ResizeObserver(holdEnd); pinned = true; viewport.scrollTo({ top: viewport.scrollHeight }); measure(); viewport.addEventListener('scroll', handleScroll, { passive: true }); observer.observe(content); observer.observe(viewport); onCleanup(() => { viewport.removeEventListener('scroll', handleScroll); observer.disconnect(); }); }, { flush: 'post' } ); // Pinning first lets the resize observer carry the scroll the rest of the // way when content grows mid-animation. function scrollToEnd() { const viewport = viewportNode.value; if (!viewport) return; pinned = true; leavingEnd = false; smoothScrollTo(viewport, viewport.scrollHeight); } function scrollToStart() { const viewport = viewportNode.value; // Already at the start, no scroll event would follow to clear the flag. if (!viewport || viewport.scrollTop === 0) return; pinned = false; leavingEnd = true; smoothScrollTo(viewport, 0); } provide(MessageScrollerKey, { isScrollable: readonly(isScrollable), isAtEnd: readonly(isAtEnd), isAtStart: readonly(isAtStart), scrollToEnd, scrollToStart, setViewport: (node) => { viewportNode.value = node; }, setContent: (node) => { contentNode.value = node; }, }); </script> <template> <slot :is-scrollable="isScrollable" :is-at-end="isAtEnd" :is-at-start="isAtStart" :scroll-to-end="scrollToEnd" :scroll-to-start="scrollToStart" /> </template>components/message-scroller/MessageScrollerViewport.vue <script setup lang="ts"> import { reactiveOmit } from '@vueuse/core'; import { ScrollAreaViewport, type ScrollAreaViewportProps } from 'reka-ui'; import { type HTMLAttributes, onBeforeUnmount, useTemplateRef, watch, } from 'vue'; import { cn } from '../../lib/utils'; import { useMessageScrollerNodes } from './message-scroller-context'; import { useMessageScroller } from '.'; export interface MessageScrollerViewportProps extends ScrollAreaViewportProps { class?: HTMLAttributes['class']; } const props = defineProps<MessageScrollerViewportProps>(); const delegated = reactiveOmit(props, 'class'); const { isAtStart, isAtEnd } = useMessageScroller(); const { setViewport } = useMessageScrollerNodes(); const viewport = useTemplateRef<InstanceType<typeof ScrollAreaViewport>>('viewport'); // Reka exposes the scrolling element as `viewportElement`, set only after it // renders; the component's own element is the non-scrolling wrapper inside it. watch(() => viewport.value?.viewportElement ?? null, setViewport, { immediate: true, flush: 'post', }); onBeforeUnmount(() => setViewport(null)); </script> <template> <ScrollAreaViewport v-bind="delegated" ref="viewport" data-slot="message-scroller-viewport" :class=" cn( 'nx:size-full nx:min-h-0 nx:min-w-0 nx:overscroll-contain nx:rounded-[inherit] nx:*:h-full nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default', !isAtStart && 'nx:mask-t-from-90%', !isAtEnd && 'nx:mask-b-from-90%', props.class ) " > <slot /> </ScrollAreaViewport> </template>components/message-scroller/index.ts import { cva, type VariantProps } from 'class-variance-authority'; import { inject } from 'vue'; import { type MessageScrollerContextValue, MessageScrollerKey, } from './message-scroller-context'; export { default as MessageScroller, type MessageScrollerProps, } from './MessageScroller.vue'; export { default as MessageScrollerButton, type MessageScrollerButtonProps, } from './MessageScrollerButton.vue'; export { default as MessageScrollerContent, type MessageScrollerContentProps, } from './MessageScrollerContent.vue'; export { default as MessageScrollerItem, type MessageScrollerItemProps, } from './MessageScrollerItem.vue'; export { default as MessageScrollerProvider, type MessageScrollerProviderProps, } from './MessageScrollerProvider.vue'; export { default as MessageScrollerViewport, type MessageScrollerViewportProps, } from './MessageScrollerViewport.vue'; /** * Reads or changes the scroll position from anywhere inside a * `MessageScrollerProvider`. Works outside the scroller too, such as in a * composer's send button. */ export function useMessageScroller(): MessageScrollerContextValue { const context = inject(MessageScrollerKey, null); if (!context) { throw new Error( 'useMessageScroller must be used within a <MessageScrollerProvider />' ); } return context; } export const messageScrollerButtonVariants = cva( 'nx:absolute nx:inset-x-0 nx:z-sticky nx:mx-auto nx:rounded-full nx:[&_svg]:size-4 nx:data-[active=false]:invisible', { variants: { direction: { start: 'nx:top-4', end: 'nx:bottom-4', }, }, defaultVariants: { direction: 'end', }, } ); export type MessageScrollerButtonVariants = VariantProps< typeof messageScrollerButtonVariants >;components/message-scroller/message-scroller-context.ts import { inject, type InjectionKey, type Ref } from 'vue'; /** What `useMessageScroller` returns: the scroll position, and jumps to either end. */ export interface MessageScrollerContextValue { isScrollable: Readonly<Ref<boolean>>; isAtEnd: Readonly<Ref<boolean>>; isAtStart: Readonly<Ref<boolean>>; scrollToEnd: () => void; scrollToStart: () => void; } export const MessageScrollerKey: InjectionKey< MessageScrollerContextValue & { setViewport: (node: HTMLElement | null) => void; setContent: (node: HTMLElement | null) => void; } > = Symbol('message-scroller'); /** The setters the viewport and content register their elements with. */ export function useMessageScrollerNodes() { const context = inject(MessageScrollerKey, null); if (!context) { throw new Error( 'MessageScroller parts must be used within a <MessageScrollerProvider />' ); } return context; } - 3
Keep each file at the path shown above it.
Paths are relative to
packages/vue/src/. Keep the same folders under your@/root, and the imports between files work without edits.
Usage
import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport } from '@/components/message-scroller';
Examples
Empty stream
Show an EmptyState inside the scroller before the first message, with the jump button hidden.
<script setup lang="ts">
import { PhChatText } from '@phosphor-icons/vue';
import {
EmptyState,
EmptyStateDescription,
EmptyStateHeader,
EmptyStateMedia,
EmptyStateTitle,
} from '@/components/empty-state';
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerProvider,
MessageScrollerViewport,
} from '@/components/message-scroller';
</script>
<template>
<MessageScrollerProvider>
<MessageScroller
class="nx:h-80 nx:w-full nx:max-w-lg nx:rounded-md nx:border-default nx:border-border-default nx:bg-background"
>
<MessageScrollerViewport>
<MessageScrollerContent>
<EmptyState>
<EmptyStateHeader>
<EmptyStateMedia variant="icon"><PhChatText /></EmptyStateMedia>
<EmptyStateTitle>No messages yet</EmptyStateTitle>
<EmptyStateDescription
>Send the first message to start this
conversation.</EmptyStateDescription
>
</EmptyStateHeader>
</EmptyState>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</template>
This example also needs: EmptyState.
Scroll to start
Add a direction="start" button that jumps to the oldest turn.
<script setup lang="ts">
import { ref } from 'vue';
import { AvatarFallback } from '@/components/avatar';
import { Bubble, BubbleContent } from '@/components/bubble';
import { Button } from '@/components/button';
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
} from '@/components/message';
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from '@/components/message-scroller';
const count = ref(10);
</script>
<template>
<MessageScrollerProvider v-slot="{ scrollToEnd }">
<div class="nx:flex nx:flex-col nx:gap-3">
<MessageScroller
class="nx:h-80 nx:w-full nx:max-w-lg nx:rounded-md nx:border-default nx:border-border-default nx:bg-background"
>
<MessageScrollerViewport>
<MessageScrollerContent>
<MessageScrollerItem v-for="index in count" :key="index">
<MessageGroup>
<Message :align="index % 2 === 0 ? 'end' : 'start'">
<MessageAvatar>
<AvatarFallback>{{
index % 2 === 0 ? 'YOU' : 'AB'
}}</AvatarFallback>
</MessageAvatar>
<MessageContent>
<Bubble :variant="index % 2 === 0 ? 'primary' : 'muted'">
<BubbleContent
>Turn {{ index }} in the transcript.</BubbleContent
>
</Bubble>
<MessageFooter
>09:{{
String(9 + index).padStart(2, '0')
}}</MessageFooter
>
</MessageContent>
</Message>
</MessageGroup>
</MessageScrollerItem>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
<MessageScrollerButton direction="start" />
</MessageScroller>
<div class="nx:flex nx:gap-2">
<Button size="sm" variant="outline" @click="count++"
>Append turn</Button
>
<Button
size="sm"
@click="
count++;
scrollToEnd();
"
>Send</Button
>
</div>
</div>
</MessageScrollerProvider>
</template>
This example also needs: Button.
Send from composer
Call scrollToEnd from the provider's slot props in a composer to jump to the new turn on send. A child component of the provider reads the same state from useMessageScroller.
<script setup lang="ts">
import { ref } from 'vue';
import { AvatarFallback } from '@/components/avatar';
import { Bubble, BubbleContent } from '@/components/bubble';
import { Button } from '@/components/button';
import {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
} from '@/components/message';
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from '@/components/message-scroller';
const count = ref(10);
</script>
<template>
<MessageScrollerProvider v-slot="{ scrollToEnd }">
<div class="nx:flex nx:flex-col nx:gap-3">
<MessageScroller
class="nx:h-80 nx:w-full nx:max-w-lg nx:rounded-md nx:border-default nx:border-border-default nx:bg-background"
>
<MessageScrollerViewport>
<MessageScrollerContent>
<MessageScrollerItem v-for="index in count" :key="index">
<MessageGroup>
<Message :align="index % 2 === 0 ? 'end' : 'start'">
<MessageAvatar>
<AvatarFallback>{{
index % 2 === 0 ? 'YOU' : 'AB'
}}</AvatarFallback>
</MessageAvatar>
<MessageContent>
<Bubble :variant="index % 2 === 0 ? 'primary' : 'muted'">
<BubbleContent
>Turn {{ index }} in the transcript.</BubbleContent
>
</Bubble>
<MessageFooter
>09:{{
String(9 + index).padStart(2, '0')
}}</MessageFooter
>
</MessageContent>
</Message>
</MessageGroup>
</MessageScrollerItem>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
<div class="nx:flex nx:gap-2">
<Button size="sm" variant="outline" @click="count++"
>Append turn</Button
>
<Button
size="sm"
@click="
count++;
scrollToEnd();
"
>Send</Button
>
</div>
</div>
</MessageScrollerProvider>
</template>
This example also needs: Button.
Props
MessageScroller
MessageScroller adds no props of its own; it accepts the props of the element or primitive it wraps.
MessageScrollerButton
| Prop | Type | Default | Description |
|---|---|---|---|
direction | "start" | "end" | null | "end" | —No description |
disabled | boolean | —No default documented | —No description |
loading | boolean | false | Disables the button and shows a spinner in place of its content. |
size | "default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null | "icon-sm" | —No description |
type | "button" | "submit" | "reset" | "button" | —No description |
variant | "default" | "destructive" | "error" | "outline" | "dashed" | "secondary" | "ghost" | "link" | null | "outline" | —No description |
MessageScrollerContent
MessageScrollerContent adds no props of its own; it accepts the props of the element or primitive it wraps.
MessageScrollerItem
MessageScrollerItem adds no props of its own; it accepts the props of the element or primitive it wraps.
MessageScrollerProvider
MessageScrollerProvider adds no props of its own; it accepts the props of the element or primitive it wraps.
MessageScrollerViewport
MessageScrollerViewport adds no props of its own; it accepts the props of the element or primitive it wraps.