Skip to main content
Nexus Design System
GitHub

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.

AB
Turn 1 in the transcript.
09:10
YOU
Turn 2 in the transcript.
09:11
AB
Turn 3 in the transcript.
09:12
YOU
Turn 4 in the transcript.
09:13
AB
Turn 5 in the transcript.
09:14
YOU
Turn 6 in the transcript.
09:15
AB
Turn 7 in the transcript.
09:16
YOU
Turn 8 in the transcript.
09:17
AB
Turn 9 in the transcript.
09:18
YOU
Turn 10 in the transcript.
09:19
AB
Turn 11 in the transcript.
09:20
YOU
Turn 12 in the transcript.
09:21
AB
Turn 13 in the transcript.
09:22
YOU
Turn 14 in the transcript.
09:23
'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.

No messages yet

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.

AB
Turn 1 in the transcript.
09:10
YOU
Turn 2 in the transcript.
09:11
AB
Turn 3 in the transcript.
09:12
YOU
Turn 4 in the transcript.
09:13
AB
Turn 5 in the transcript.
09:14
YOU
Turn 6 in the transcript.
09:15
AB
Turn 7 in the transcript.
09:16
YOU
Turn 8 in the transcript.
09:17
AB
Turn 9 in the transcript.
09:18
YOU
Turn 10 in the transcript.
09:19
'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.

AB
Turn 1 in the transcript.
09:10
YOU
Turn 2 in the transcript.
09:11
AB
Turn 3 in the transcript.
09:12
YOU
Turn 4 in the transcript.
09:13
AB
Turn 5 in the transcript.
09:14
YOU
Turn 6 in the transcript.
09:15
AB
Turn 7 in the transcript.
09:16
YOU
Turn 8 in the transcript.
09:17
AB
Turn 9 in the transcript.
09:18
YOU
Turn 10 in the transcript.
09:19
'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

MessageScrollerButton props
PropTypeDefaultDescription
direction"end" | "start" | null"end"No description
endIconReactNodeNo default documented

Decorative icon after the label. Use endIcon or startIcon, not both.

loadingbooleanfalse

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
startIconReactNodeNo default documented

Decorative icon before the label. Use startIcon or endIcon, not both.

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:

    Button, ButtonGroup, ScrollArea, Spinner.

  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

MessageScrollerButton props
PropTypeDefaultDescription
direction"start" | "end" | null"end"No description
disabledbooleanNo default documentedNo description
loadingbooleanfalse

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.